手把手带你构建Sui的索引器及RPC服务 @SUI Move开发必知必会
索引器(indexer
) 是从链上获取数据,进过处理后,存储到链下数据库,并提供API
接口供查询的服务。
使用索引器的好处:
API
服务系列文章目录:
$ mkdir indexer
$ pnpm init
$ pnpm add typescript prisma ts-node @types/node -D
$ pnpm add @mysten/sui @prisma/client
之所以采用
sqlite
是一个文件数据库,是为了方便使用,不需要额外部署其它数据库服务,但查询的能力会受限。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
/// We can setup the provider to our database
/// For this DEMO, we're using sqlite, which allows us to not
/// have external dependencies.
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
/// Our `Locked` objects list
model Locked {
// Keeping an ID so we can use as a pagination cursor
// There's an issue with BigInt for sqlite, so we're using a plain ID.
id Int @id @default(autoincrement())
objectId String @unique
keyId String?
creator String?
itemId String?
deleted Boolean @default(false)
@@index([creator])
@@index([deleted])
}
/// Our swap objects list
model Escrow {
// Keeping an ID so we can use as a pagination cursor
// There's an issue with BigInt for sqlite, so we're using a plain ID.
id Int @id @default(autoincrement())
objectId String @unique
sender String?
recipient String?
keyId String?
itemId String?
swapped Boolean @default(false)
cancelled Boolean @default(false)
@@index([recipient])
@@index([sender])
}
/// Saves the latest cursor for a given key.
model Cursor {
id String @id
eventSeq String
txDigest String
}
$ npx prisma migrate dev --name init
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
SQLite database dev.db created at file:./dev.db
Applying migration `20240623141512_init`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20240623141512_init/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (v5.15.1) to ./node_modules/.pnpm/@prisma+client@5.15.1_prisma@5.15.1/node_modules/@prisma/client in 46ms
这里只是
prisma
工具迁移操作会生成使用的建表语句,但实际上不需要用户去执行以下语句。
CREATE TABLE "Locked" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"objectId" TEXT NOT NULL,
"keyId" TEXT,
"creator" TEXT,
"itemId" TEXT,
"deleted" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "Escrow" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"objectId" TEXT NOT NULL,
"sender" TEXT,
"recipient" TEXT,
"keyId" TEXT,
"itemId" TEXT,
"swapped" BOOLEAN NOT NULL DEFAULT false,
"cancelled" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "Cursor" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventSeq" TEXT NOT NULL,
"txDigest" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Locked_objectId_key" ON "Locked"("objectId");
-- CreateIndex
CREATE INDEX "Locked_creator_idx" ON "Locked"("creator");
-- CreateIndex
CREATE INDEX "Locked_deleted_idx" ON "Locked"("deleted");
-- CreateIndex
CREATE UNIQUE INDEX "Escrow_objectId_key" ON "Escrow"("objectId");
-- CreateIndex
CREATE INDEX "Escrow_recipient_idx" ON "Escrow"("recipient");
-- CreateIndex
CREATE INDEX "Escrow_sender_idx" ON "Escrow"("sender");
将会监听以下两个模块产生的事件,可以在这里查看到具体的合约实现:https://github.com/MystenLabs/sui/tree/mainnet-v1.27.2/examples/trading/contracts/escrow/sources
lock
shared
type EventTracker = {
// The module that defines the type, with format `package::module`
type: string;
filter: SuiEventFilter;
callback: (events: SuiEvent[], type: string) => any;
};
const EVENTS_TO_TRACK: EventTracker[] = [
{
type: `${CONFIG.SWAP_CONTRACT.packageId}::lock`,
filter: {
MoveEventModule: {
module: 'lock',
package: CONFIG.SWAP_CONTRACT.packageId,
},
},
callback: handleLockObjects,
},
{
type: `${CONFIG.SWAP_CONTRACT.packageId}::shared`,
filter: {
MoveEventModule: {
module: 'shared',
package: CONFIG.SWAP_CONTRACT.packageId,
},
},
callback: handleEscrowObjects,
},
];
lock
合约事件方法type LockEvent = LockCreated | LockDestroyed;
type LockCreated = {
creator: string;
lock_id: string;
key_id: string;
item_id: string;
};
type LockDestroyed = {
lock_id: string;
};
export const handleLockObjects = async (events: SuiEvent[], type: string) => {
const updates: Record<string, Prisma.LockedCreateInput> = {};
for (const event of events) {
if (!event.type.startsWith(type))
throw new Error("Invalid event module origin");
const data = event.parsedJson as LockEvent;
const isDeletionEvent = !("key_id" in data);
if (!Object.hasOwn(updates, data.lock_id)) {
updates[data.lock_id] = {
objectId: data.lock_id,
};
}
// Handle deletion
if (isDeletionEvent) {
updates[data.lock_id].deleted = true;
continue;
}
// Handle creation event
updates[data.lock_id].keyId = data.key_id;
updates[data.lock_id].creator = data.creator;
updates[data.lock_id].itemId = data.item_id;
}
// As part of the demo and to avoid having external dependencies, we use SQLite as our database.
// Prisma + SQLite does not support bulk insertion & conflict handling, so we have to insert these 1 by 1
// (resulting in multiple round-trips to the database).
// Always use a single `bulkInsert` query with proper `onConflict` handling in production databases (e.g Postgres)
const promises = Object.values(updates).map((update) =>
prisma.locked.upsert({
where: {
objectId: update.objectId,
},
create: {
...update,
},
update,
})
);
await Promise.all(promises);
};
shared
合约事件方法type EscrowEvent = EscrowCreated | EscrowCancelled | EscrowSwapped;
type EscrowCreated = {
sender: string;
recipient: string;
escrow_id: string;
key_id: string;
item_id: string;
};
type EscrowSwapped = {
escrow_id: string;
};
type EscrowCancelled = {
escrow_id: string;
};
export const handleEscrowObjects = async (events: SuiEvent[], type: string) => {
const updates: Record<string, Prisma.EscrowCreateInput> = {};
for (const event of events) {
if (!event.type.startsWith(type))
throw new Error("Invalid event module origin");
const data = event.parsedJson as EscrowEvent;
if (!Object.hasOwn(updates, data.escrow_id)) {
updates[data.escrow_id] = {
objectId: data.escrow_id,
};
}
// Escrow cancellation case
if (event.type.endsWith("::EscrowCancelled")) {
const data = event.parsedJson as EscrowCancelled;
updates[data.escrow_id].cancelled = true;
continue;
}
// Escrow swap case
if (event.type.endsWith("::EscrowSwapped")) {
const data = event.parsedJson as EscrowSwapped;
updates[data.escrow_id].swapped = true;
continue;
}
const creationData = event.parsedJson as EscrowCreated;
// Handle creation event
updates[data.escrow_id].sender = creationData.sender;
updates[data.escrow_id].recipient = creationData.recipient;
updates[data.escrow_id].keyId = creationData.key_id;
updates[data.escrow_id].itemId = creationData.item_id;
}
// As part of the demo and to avoid having external dependencies, we use SQLite as our database.
// Prisma + SQLite does not support bulk insertion & conflict handling, so we have to insert these 1 by 1
// (resulting in multiple round-trips to the database).
// Always use a single `bulkInsert` query with proper `onConflict` handling in production databases (e.g Postgres)
const promises = Object.values(updates).map((update) =>
prisma.escrow.upsert({
where: {
objectId: update.objectId,
},
create: update,
update,
})
);
await Promise.all(promises);
};
索引器会遍历我们关注的事件(EVENTS_TO_TRACK
),并未每个事件调用runEventJob
方法,该方法会从上一次订阅结束的位置(getLatestCursor
)开始订阅
/// They are polling the RPC endpoint every second.
export const setupListeners = async () => {
for (const event of EVENTS_TO_TRACK) {
runEventJob(getClient(CONFIG.NETWORK), event, await getLatestCursor(event));
}
};
/**
* Gets the latest cursor for an event tracker, either from the DB (if it's undefined)
* or from the running cursors.
*/
const getLatestCursor = async (tracker: EventTracker) => {
const cursor = await prisma.cursor.findUnique({
where: {
id: tracker.type,
},
});
return cursor || undefined;
};
将递归循环调用事件订阅函数,订阅关注事件,一轮订阅完成后,如果还有没处理完的事件会立即开始新一轮订阅,否则休眠1s后,在此开始新一轮订阅
type SuiEventsCursor = EventId | null | undefined;
const runEventJob = async (
client: SuiClient,
tracker: EventTracker,
cursor: SuiEventsCursor
) => {
const result = await executeEventJob(client, tracker, cursor);
// Trigger a timeout. Depending on the result, we either wait 0ms or the polling interval.
setTimeout(
() => {
runEventJob(client, tracker, result.cursor);
},
result.hasNextPage ? 0 : CONFIG.POLLING_INTERVAL_MS
);
};
执行事件订阅
调用queryEvents
方法,采用递增方式分页去获取事件
订阅到数据后,进行回调,更新到数据库
如果获取到了订阅数据,便会更新最近订阅游标
返回最新的游标以及是否还有下一页布尔值
type EventExecutionResult = {
cursor: SuiEventsCursor;
hasNextPage: boolean;
};
const executeEventJob = async (
client: SuiClient,
tracker: EventTracker,
cursor: SuiEventsCursor
): Promise<EventExecutionResult> => {
try {
// get the events from the chain.
// For this implementation, we are going from start to finish.
// This will also allow filling in a database from scratch!
const { data, hasNextPage, nextCursor } = await client.queryEvents({
query: tracker.filter,
cursor,
order: "ascending",
});
// handle the data transformations defined for each event
await tracker.callback(data, tracker.type);
// We only update the cursor if we fetched extra data (which means there was a change).
if (nextCursor && data.length > 0) {
await saveLatestCursor(tracker, nextCursor);
return {
cursor: nextCursor,
hasNextPage,
};
}
} catch (e) {
console.error(e);
}
// By default, we return the same cursor as passed in.
return {
cursor,
hasNextPage: false,
};
};
/**
* Saves the latest cursor for an event tracker to the db, so we can resume
* from there.
* */
const saveLatestCursor = async (tracker: EventTracker, cursor: EventId) => {
const data = {
eventSeq: cursor.eventSeq,
txDigest: cursor.txDigest,
};
return prisma.cursor.upsert({
where: {
id: tracker.type,
},
update: data,
create: { id: tracker.type, ...data },
});
};
执行启动命令:npx ts-node indexer.ts
,可以观察到以下日志,这是将我们在《SUI Move官方示例合约实践——NFT类:零信任原子交换(trustless swap)》一文中测试添加的数据订阅了下来。
Prisma Studio GUI
进行查看数据$ npx prisma studio
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555
至此我们便完成了索引器的创建,接下来我们将以下将结合使用 Express
构建一个索引器的 RESTful API
服务,方便应用系统进行访问查询链上数据。
$ mkdir api-service
$ pnpm init
$ pnpm add typescript prisma ts-node @types/node @types/cors @types/express -D
$ pnpm add @mysten/sui @prisma/client express cors
where
条件解析该函数将提取URL
查询字符串并将其解析为Prisma
的有效查询参数。
export enum WhereParamTypes {
STRING,
NUMBER,
BOOLEAN,
}
export type WhereParam = {
key: string;
type: WhereParamTypes;
};
/** Parses a where statement based on the query params. */
export const parseWhereStatement = (
query: Record<string, any>,
acceptedParams: WhereParam[]
) => {
const params: Record<string, any> = {};
for (const key of Object.keys(query)) {
const whereParam = acceptedParams.find((x) => x.key === key);
if (!whereParam) continue;
const value = query[key];
if (whereParam.type === WhereParamTypes.STRING) {
params[key] = value;
}
if (whereParam.type === WhereParamTypes.NUMBER) {
const number = Number(value);
if (isNaN(number)) throw new Error(`Invalid number for ${key}`);
params[key] = number;
}
// Handle boolean expected values.
if (whereParam.type === WhereParamTypes.BOOLEAN) {
let boolValue;
if (value === "true") boolValue = true;
else if (value === "false") boolValue = false;
else throw new Error(`Invalid boolean for ${key}`);
params[key] = boolValue;
}
}
return params;
};
export type ApiPagination = {
take?: number;
orderBy: {
id: "asc" | "desc";
};
cursor?: {
id: number;
};
skip?: number;
};
/**
* A helper to prepare pagination based on `req.query`.
* We are doing only primary key cursor + ordering for this example.
*/
export const parsePaginationForQuery = (body: Record<string, any>) => {
const pagination: ApiPagination = {
orderBy: {
id:
Object.hasOwn(body, "sort") && ["asc", "desc"].includes(body.sort)
? body.sort
: "desc",
},
};
// Prepare pagination limit (how many items to return)
if (Object.hasOwn(body, "limit")) {
const requestLimit = Number(body.limit);
if (isNaN(requestLimit)) throw new Error("Invalid limit value");
pagination.take =
requestLimit > CONFIG.DEFAULT_LIMIT ? CONFIG.DEFAULT_LIMIT : requestLimit;
} else {
pagination.take = CONFIG.DEFAULT_LIMIT;
}
// Prepare cursor pagination (which page to return)
if (Object.hasOwn(body, "cursor")) {
const cursor = Number(body.cursor);
if (isNaN(cursor)) throw new Error("Invalid cursor");
pagination.skip = 1;
pagination.cursor = {
id: cursor,
};
}
return pagination;
};
const app = express();
app.use(cors());
app.use(express.json());
app.get("/", async (req, res) => {
return res.send({ message: "🚀 API is functional 🚀" });
});
app.get("/locked", async (req, res) => {
const acceptedQueries: WhereParam[] = [
{
key: "deleted",
type: WhereParamTypes.BOOLEAN,
},
{
key: "creator",
type: WhereParamTypes.STRING,
},
{
key: "keyId",
type: WhereParamTypes.STRING,
},
{
key: "objectId",
type: WhereParamTypes.STRING,
},
];
try {
const locked = await prisma.locked.findMany({
where: parseWhereStatement(req.query, acceptedQueries)!,
...parsePaginationForQuery(req.query),
});
return res.send(formatPaginatedResponse(locked));
} catch (e) {
console.error(e);
return res.status(400).send(e);
}
});
app.get("/escrows", async (req, res) => {
const acceptedQueries: WhereParam[] = [
{
key: "cancelled",
type: WhereParamTypes.BOOLEAN,
},
{
key: "swapped",
type: WhereParamTypes.BOOLEAN,
},
{
key: "recipient",
type: WhereParamTypes.STRING,
},
{
key: "sender",
type: WhereParamTypes.STRING,
},
];
try {
const escrows = await prisma.escrow.findMany({
where: parseWhereStatement(req.query, acceptedQueries)!,
...parsePaginationForQuery(req.query),
});
return res.send(formatPaginatedResponse(escrows));
} catch (e) {
console.error(e);
return res.status(400).send(e);
}
});
app.listen(3000, () =>
console.log(`🚀 Server ready at: http://localhost:3000`)
);
$ npx ts-node server.ts
🚀 Server ready at: http://localhost:3000
$ curl -s http://localhost:3000 | jq
{
"message": "🚀 API is functional 🚀"
}
locked
对象$ curl -s http://localhost:3000/locked | jq
{
"data": [
{
"id": 5,
"objectId": "0x226c677dfc18472b5b35430d5cd1adb7036b365bb0c5c1576750d39c405fca37",
"keyId": "0x2db3ad1a48784e5bde665aa59c2991b269204f220dfbe9bc42afb3137abb1649",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"deleted": true
},
{
"id": 4,
"objectId": "0x2be9b0606ab27d387c72887d8f0a8c3f06fb91082a4e31ab20e0e2d7c82d12f3",
"keyId": "0x0f5422ffa583ce9a2e29d9a9c2fd653996a84cf28acf8a90c9a21c1eb364f11b",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x0cf1370cc575da5cd271930683b32c14a284cf2a0d6bef57c32367eaa08dd605",
"deleted": false
},
......
$ curl -s http://localhost:3000/locked?objectId=0xb3a15c074e8911c1318dcc0bf9bd957bfe30b191c7578ca457b529ccfd4718c4 | jq
{
"data": [
{
"id": 1,
"objectId": "0xb3a15c074e8911c1318dcc0bf9bd957bfe30b191c7578ca457b529ccfd4718c4",
"keyId": "0x5476982bcd1a83126d03f7b313924f1943abad14472c707dda48499c8132ee49",
"creator": "0x956e69bc6b23b593bce92694f990e7a4306ee53543f69e4a1eaa716f4609ec08",
"itemId": "0x2a835cae60a341b2837d0ad36affc501d670018dfccd1ba09e7aff7bc106cfde",
"deleted": false
}
],
"cursor": 1
}
$ curl -s "http://localhost:3000/locked?keyId=0x2db3ad1a48784e5bde665aa59c2991b269204f220dfbe9bc42afb3137abb1649&deleted=true" | jq
{
"data": [
{
"id": 5,
"objectId": "0x226c677dfc18472b5b35430d5cd1adb7036b365bb0c5c1576750d39c405fca37",
"keyId": "0x2db3ad1a48784e5bde665aa59c2991b269204f220dfbe9bc42afb3137abb1649",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"deleted": true
}
],
"cursor": 5
}
$ curl -s "http://localhost:3000/locked?sort=asc&limit=2&cursor=3" | jq
{
"data": [
{
"id": 4,
"objectId": "0x2be9b0606ab27d387c72887d8f0a8c3f06fb91082a4e31ab20e0e2d7c82d12f3",
"keyId": "0x0f5422ffa583ce9a2e29d9a9c2fd653996a84cf28acf8a90c9a21c1eb364f11b",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x0cf1370cc575da5cd271930683b32c14a284cf2a0d6bef57c32367eaa08dd605",
"deleted": false
},
{
"id": 5,
"objectId": "0x226c677dfc18472b5b35430d5cd1adb7036b365bb0c5c1576750d39c405fca37",
"keyId": "0x2db3ad1a48784e5bde665aa59c2991b269204f220dfbe9bc42afb3137abb1649",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"deleted": true
}
],
"cursor": 5
}
$ curl -s "http://localhost:3000/locked?sort=asc&limit=2&cursor=4" | jq
{
"data": [
{
"id": 5,
"objectId": "0x226c677dfc18472b5b35430d5cd1adb7036b365bb0c5c1576750d39c405fca37",
"keyId": "0x2db3ad1a48784e5bde665aa59c2991b269204f220dfbe9bc42afb3137abb1649",
"creator": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"deleted": true
}
],
"cursor": 5
}
$ curl -s http://localhost:3000/escrows | jq
{
"data": [
{
"id": 2,
"objectId": "0xd829028ba9c7d28aa579d60402b02f346734f2534e56873a71e713fd2e5f715f",
"sender": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0",
"recipient": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19",
"keyId": "0xb524ee7c3464460d7b247423bcb0ed762986fe5d1d88647c3dceccbeec2e71a5",
"itemId": "0x4062011a41b15825b32645f7c118e435d9a97e1291bc3b6f126760bf16f021fb",
"swapped": true,
"cancelled": false
},
{
"id": 1,
"objectId": "0x5b24e3b1423b5ef665ac236ed44282acb0f25f914a6a37fe782dad2af7855d58",
"sender": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"recipient": "0x956e69bc6b23b593bce92694f990e7a4306ee53543f69e4a1eaa716f4609ec08",
"keyId": "0x5476982bcd1a83126d03f7b313924f1943abad14472c707dda48499c8132ee49",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"swapped": false,
"cancelled": false
}
],
"cursor": 1
}
$ curl -s "http://localhost:3000/escrows?sender=0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5&recipient=0x956e69bc6b23b593bce92694f990e7a4306ee53543f69e4a1eaa716f4609ec08" | jq
{
"data": [
{
"id": 1,
"objectId": "0x5b24e3b1423b5ef665ac236ed44282acb0f25f914a6a37fe782dad2af7855d58",
"sender": "0xae6c01c35619d83192f82704aa3ab9b05954acb898636c6e3c1d53ce7f0265e5",
"recipient": "0x956e69bc6b23b593bce92694f990e7a4306ee53543f69e4a1eaa716f4609ec08",
"keyId": "0x5476982bcd1a83126d03f7b313924f1943abad14472c707dda48499c8132ee49",
"itemId": "0x1a0890c4a6a973f5f3fe612c7ac4574190547c80ecdddbc0430c76e927c2d270",
"swapped": false,
"cancelled": false
}
],
"cursor": 1
}
$ curl -s "http://localhost:3000/escrows?sort=asc&limit=1&cursor=1" | jq
{
"data": [
{
"id": 2,
"objectId": "0xd829028ba9c7d28aa579d60402b02f346734f2534e56873a71e713fd2e5f715f",
"sender": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0",
"recipient": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19",
"keyId": "0xb524ee7c3464460d7b247423bcb0ed762986fe5d1d88647c3dceccbeec2e71a5",
"itemId": "0x4062011a41b15825b32645f7c118e435d9a97e1291bc3b6f126760bf16f021fb",
"swapped": true,
"cancelled": false
}
],
"cursor": 2
}
https://docs.sui.io/guides/developer/app-examples/trustless-swap/indexer-api
欢迎关注微信公众号:Move中文,开启你的 Sui Move 之旅!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!