diff --git a/Makefile b/Makefile index 8d89085..bafb4e5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: all clean check +.PHONY: all clean check test DEPS := $(wildcard packages/*/package.json) -PACKAGES := borsh sdk-core sdk-solana sdk-sui sdk-facade +PACKAGES := borsh sdk-core sdk-solana sdk-facade ALL_TARGETS := $(foreach pkg,$(PACKAGES),packages/$(pkg)/lib) all: $(ALL_TARGETS) @@ -11,7 +11,8 @@ clean: rm -rf $(ALL_TARGETS) @echo Build cleaned! -node_modules: $(DEPS) +node_modules/: $(DEPS) + @echo Install dependencies npm i -ws check: @@ -19,8 +20,13 @@ check: npm run check --workspace=@race-foundation/$(PKG) @echo make: Leaving directory "'packages/$(PKG)'" +test: + @echo make: Entering directory "'packages/$(PKG)'" + npm run test --workspace=@race-foundation/$(PKG) + @echo make: Leaving directory "'packages/$(PKG)'" + define LIB_template -packages/$(1)/lib: node_modules $$(wildcard packages/$(1)/src/*.ts wildcard packages/$(1)/src/**/*.ts packages/$(1)/*.js packages/$(1)/*.json) +packages/$(1)/lib: node_modules/ $$(wildcard packages/$(1)/src/*.ts wildcard packages/$(1)/src/**/*.ts packages/$(1)/*.js packages/$(1)/*.json) @echo make: Entering directory "'packages/$(1)'" npm run check --workspace=@race-foundation/$(1) npm run build --workspace=@race-foundation/$(1) diff --git a/package.json b/package.json index 723dfb0..f210491 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "packages/sdk-core", "packages/sdk-solana", "packages/sdk-facade", - "packages/borsh", - "packages/sdk-sui" + "packages/borsh" ], "version": "0.1.0" } diff --git a/packages/sdk-core/src/account-cache.ts b/packages/sdk-core/src/account-cache.ts index d194d8b..ab17369 100644 --- a/packages/sdk-core/src/account-cache.ts +++ b/packages/sdk-core/src/account-cache.ts @@ -1,4 +1,4 @@ -import { GameAccount } from './accounts' +import { IGameAccount } from './accounts' /** * We use this structure to cache some useful data from the fetched @@ -16,7 +16,7 @@ export interface GameAccountCache { transactorEndpoint: string | undefined } -export function makeGameAccountCache(gameAccount: GameAccount): GameAccountCache { +export function makeGameAccountCache(gameAccount: IGameAccount): GameAccountCache { return { addr: gameAccount.addr, bundleAddr: gameAccount.bundleAddr, diff --git a/packages/sdk-core/src/accounts.ts b/packages/sdk-core/src/accounts.ts index ab0646e..e72fdb4 100644 --- a/packages/sdk-core/src/accounts.ts +++ b/packages/sdk-core/src/accounts.ts @@ -1,5 +1,7 @@ import { CheckpointOnChain } from './checkpoint' import { IKind, UnionFromValues } from './types' +import { IEntryType } from './entry-type' +import { IPlayerBalance } from './player-balance' export const ENTRY_LOCKS = ['Open', 'JoinOnly', 'DepositOnly', 'Closed'] as const export type EntryLock = UnionFromValues @@ -7,14 +9,13 @@ export type EntryLock = UnionFromValues export const DEPOSIT_STATUS = ['Pending', 'Rejected', 'Refunded', 'Accepted'] as const export type DepositStatus = UnionFromValues -export interface PlayerJoin { +export interface IPlayerJoin { readonly addr: string readonly position: number readonly accessVersion: bigint - readonly verifyKey: string } -export interface PlayerDeposit { +export interface IPlayerDeposit { readonly addr: string readonly amount: bigint readonly accessVersion: bigint @@ -22,14 +23,13 @@ export interface PlayerDeposit { readonly status: DepositStatus } -export interface ServerJoin { +export interface IServerJoin { readonly addr: string readonly endpoint: string readonly accessVersion: bigint - readonly verifyKey: string } -export interface Bonus { +export interface IBonus { readonly identifier: string readonly tokenAddr: string readonly amount: bigint @@ -38,25 +38,20 @@ export interface Bonus { export const VOTE_TYPES = ['ServerVoteTransactorDropOff', 'ClientVoteTransactorDropOff'] as const export type VoteType = UnionFromValues -export interface Vote { +export interface IVote { readonly voter: string readonly votee: string readonly voteType: VoteType } -export interface GameRegistration { +export interface IGameRegistration { readonly title: string readonly addr: string readonly regTime: bigint readonly bundleAddr: string } -export interface PlayerBalance { - readonly playerId: bigint - readonly balance: bigint -} - -export interface GameAccount { +export interface IGameAccount { readonly addr: string readonly title: string readonly bundleAddr: string @@ -64,50 +59,52 @@ export interface GameAccount { readonly ownerAddr: string readonly settleVersion: bigint readonly accessVersion: bigint - readonly players: PlayerJoin[] - readonly deposits: PlayerDeposit[] - readonly servers: ServerJoin[] + readonly players: IPlayerJoin[] + readonly deposits: IPlayerDeposit[] + readonly servers: IServerJoin[] readonly transactorAddr: string | undefined - readonly votes: Vote[] + readonly votes: IVote[] readonly unlockTime: bigint | undefined readonly maxPlayers: number readonly dataLen: number readonly data: Uint8Array - readonly entryType: EntryType + readonly entryType: IEntryType readonly recipientAddr: string readonly checkpointOnChain: CheckpointOnChain | undefined readonly entryLock: EntryLock - readonly bonuses: Bonus[] - readonly balances: PlayerBalance[] + readonly bonuses: IBonus[] + readonly balances: IPlayerBalance[] } -export interface ServerAccount { +export interface IServerAccount { readonly addr: string readonly endpoint: string + readonly credentials: Uint8Array } -export interface GameBundle { +export interface IGameBundle { readonly addr: string readonly uri: string readonly name: string readonly data: Uint8Array } -export interface PlayerProfile { +export interface IPlayerProfile { readonly addr: string readonly nick: string readonly pfp: string | undefined + readonly credentials: Uint8Array } -export interface RegistrationAccount { +export interface IRegistrationAccount { readonly addr: string readonly isPrivate: boolean readonly size: number readonly owner: string | undefined - readonly games: GameRegistration[] + readonly games: IGameRegistration[] } -export interface Token { +export interface IToken { readonly addr: string readonly icon: string readonly name: string @@ -120,7 +117,7 @@ export class TokenBalance { readonly amount!: bigint } -export interface Nft { +export interface INft { readonly addr: string readonly image: string readonly name: string @@ -129,73 +126,54 @@ export interface Nft { readonly metadata: any } -export interface RecipientAccount { +export interface IRecipientAccount { readonly addr: string readonly capAddr: string | undefined - readonly slots: RecipientSlot[] + readonly slots: IRecipientSlot[] } export const RECIPIENT_SLOT_TYPES = ['Nft', 'Token'] as const export type RecipientSlotType = UnionFromValues -export interface RecipientSlot { +export interface IRecipientSlot { readonly id: number readonly slotType: RecipientSlotType readonly tokenAddr: string - readonly shares: RecipientSlotShare[] + readonly shares: IRecipientSlotShare[] readonly balance: bigint } -export interface RecipientSlotShare { - readonly owner: RecipientSlotOwner +export interface IRecipientSlotShare { + readonly owner: IRecipientSlotOwner readonly weights: number readonly claimAmount: bigint } -export type RecipientSlotOwnerKind = IKind +export type RecipientSlotOwnerKind = IKind export type RecipientSlotOwnerUnassigned = { readonly identifier: string -} & RecipientSlotOwnerKind<'unassigned'> +} & RecipientSlotOwnerKind<'Unassigned'> export type RecipientSlotOwnerAssigned = { readonly addr: string -} & RecipientSlotOwnerKind<'assigned'> - -export type RecipientSlotOwner = RecipientSlotOwnerUnassigned | RecipientSlotOwnerAssigned - -export type EntryTypeKind = IKind - -export type EntryTypeCash = { - readonly minDeposit: bigint - readonly maxDeposit: bigint -} & EntryTypeKind<'cash'> - -export type EntryTypeTicket = { - readonly amount: bigint -} & EntryTypeKind<'ticket'> - -export type EntryTypeGating = { - readonly collection: string -} & EntryTypeKind<'gating'> - -export type EntryTypeDisabled = {} & EntryTypeKind<'disabled'> +} & RecipientSlotOwnerKind<'Assigned'> -export type EntryType = EntryTypeCash | EntryTypeTicket | EntryTypeGating | EntryTypeDisabled +export type IRecipientSlotOwner = RecipientSlotOwnerUnassigned | RecipientSlotOwnerAssigned /** * The registration account data with games consolidated. */ -export interface RegistrationWithGames { +export interface IRegistrationWithGames { readonly addr: string readonly isPrivate: boolean readonly size: number readonly owner: string | undefined - readonly games: GameAccount[] + readonly games: IGameAccount[] } -function getEndpointFromGameAccount(gameAccount: GameAccount): string | undefined { +function getEndpointFromGameAccount(gameAccount: IGameAccount): string | undefined { const { transactorAddr, servers } = gameAccount if (!transactorAddr) { diff --git a/packages/sdk-core/src/app-client.ts b/packages/sdk-core/src/app-client.ts index e57aaf5..798add4 100644 --- a/packages/sdk-core/src/app-client.ts +++ b/packages/sdk-core/src/app-client.ts @@ -7,7 +7,7 @@ import { SdkError } from './error' import { Client } from './client' import { DecryptionCache } from './decryption-cache' import { BaseClient } from './base-client' -import { GameAccount, GameBundle, Token } from './accounts' +import { IGameAccount, IGameBundle, IToken } from './accounts' import { ConnectionStateCallbackFunction, EventCallbackFunction, @@ -20,6 +20,7 @@ import { ProfileCallbackFunction, } from './types' import { SubClient } from './sub-client' +import { SharedData } from './shared-data' import { Checkpoint } from './checkpoint' import { IProfileLoader, ProfileLoader } from './profile-loader' import { IStorage } from './storage' @@ -27,7 +28,7 @@ import { GameContextSnapshot } from './game-context-snapshot' export type AppClientInitOpts = { transport: ITransport - storage?: IStorage + storage: IStorage gameAddr: string playerAddr: string onEvent: EventCallbackFunction @@ -52,12 +53,12 @@ export type SubClientInitOpts = { export type AppClientCtorOpts = { gameAddr: string - gameAccount: GameAccount + gameAccount: IGameAccount playerAddr: string handler: Handler client: Client transport: ITransport - storage?: IStorage + storage: IStorage encryptor: IEncryptor profileLoader: IProfileLoader connection: IConnection @@ -76,7 +77,7 @@ export type AppClientCtorOpts = { export class AppClient extends BaseClient { __endpoint: string - __latestGameAccount: GameAccount + __latestGameAccount: IGameAccount constructor(opts: AppClientCtorOpts) { super({ @@ -113,11 +114,14 @@ export class AppClient extends BaseClient { let startTime = new Date().getTime() console.info(`Player address: ${playerAddr}`) + console.info(`Fetch game account: ${gameAddr}`) const gameAccount = await transport.getGameAccount(gameAddr) - console.info('Game Account:', gameAccount) + if (gameAccount === undefined) { throw SdkError.gameAccountNotFound(gameAddr) } + console.info('Game account:', gameAccount) + const transactorAddr = gameAccount.transactorAddr console.info(`Transactor address: ${transactorAddr}`) @@ -125,10 +129,13 @@ export class AppClient extends BaseClient { throw SdkError.gameNotServed(gameAddr) } - let token: Token | undefined = await transport.getToken(gameAccount.tokenAddr) + let token: IToken | undefined = await transport.getToken(gameAccount.tokenAddr) + + const encryptor = new Encryptor() + + console.info(`Fetch game bundle ${gameAccount.bundleAddr} and transactor account ${transactorAddr}`) - const [encryptor, gameBundle, transactorAccount] = await Promise.all([ - Encryptor.create(playerAddr, storage), + const [gameBundle, transactorAccount] = await Promise.all([ getGameBundle(transport, storage, gameAccount.bundleAddr), transport.getServerAccount(transactorAddr), ]) @@ -139,8 +146,13 @@ export class AppClient extends BaseClient { if (transactorAccount === undefined) { throw SdkError.transactorAccountNotFound(transactorAddr) } + + console.info('Game bundle:', gameBundle) + console.info('Transactor account:', transactorAccount) + const decryptionCache = new DecryptionCache() const endpoint = transactorAccount.endpoint + const connection = Connection.initialize(gameAddr, playerAddr, endpoint, encryptor) const profileLoader = new ProfileLoader(transport, storage, onProfile) @@ -164,8 +176,6 @@ export class AppClient extends BaseClient { } } - console.info('The onchain part of checkpoint:', gameAccount.checkpointOnChain) - console.info('The offchain part of checkpoint:', checkpointOffChain) let checkpoint if (checkpointOffChain !== undefined && gameAccount.checkpointOnChain !== undefined) { checkpoint = Checkpoint.fromParts(checkpointOffChain, gameAccount.checkpointOnChain) @@ -173,7 +183,7 @@ export class AppClient extends BaseClient { throw SdkError.gameNotServed(gameAddr) } - const gameContext = new GameContext(gameAccount, checkpoint) + const gameContext = new GameContext(checkpoint.sharedData.generalize(), checkpoint.rootData) if (token === undefined) { const decimals = await transport.getTokenDecimals(gameAccount.tokenAddr) @@ -241,7 +251,8 @@ export class AppClient extends BaseClient { const addr = `${this.__gameAddr}:${gameId.toString()}` console.group(`SubClient initialization, id: ${gameId}`) - console.info('Versioned data:', this.__gameContext.checkpoint.getVersionedData(gameId)) + console.info('Versioned data:', this.__gameContext.versionedData.getSubData(gameId)) + const subGame = this.__gameContext.findSubGame(gameId) @@ -256,18 +267,41 @@ export class AppClient extends BaseClient { const decryptionCache = new DecryptionCache() const playerAddr = this.__playerAddr + + + const gameBundle = await getGameBundle(this.__transport, this.__storage, bundleAddr) const connection = Connection.initialize(addr, playerAddr, this.__endpoint, this.__encryptor) const client = new Client(playerAddr, this.__encryptor, connection) - const gameBundle = await getGameBundle(this.__transport, this.__storage, bundleAddr) - const handler = await Handler.initialize(gameBundle, this.__encryptor, client, decryptionCache) - const gameContext = this.__gameContext.subContext(subGame) + const [handler, checkpointOffChain] = await Promise.all([ + Handler.initialize(gameBundle, this.__encryptor, client, decryptionCache), + connection.getLatestCheckpoint(), + ]) + + if (checkpointOffChain === undefined) { + throw new Error(`Cannot get checkpoint from transactor for subgame: ${subGame.gameId}`) + } + + /// XXX create a context for subgame + /// If the context is created from versioned data, we just need a versioned data for sub game + + const subVersionedData = checkpointOffChain.rootData; + + console.log(subVersionedData) + + const sharedData = { + balances: this.__gameContext.balances, + nodes: this.__gameContext.nodes + } + + const gameContext = new GameContext(sharedData, subVersionedData) return new SubClient({ gameAddr: addr, playerAddr: this.__playerAddr, transport: this.__transport, encryptor: this.__encryptor, + storage: this.__storage, onEvent, onMessage, onTxState, @@ -295,9 +329,12 @@ export class AppClient extends BaseClient { */ async attachGame() { try { + console.info('Connecting to transactor') this.__connect() - await this.__attachGameWithRetry() + console.info('Connected to transactor') + console.info('Establishing event subscription') this.__startSubscribe() + console.info('Event subscription Established') } catch (e) { console.error(this.__logPrefix + 'Attaching game failed', e) this.__invokeErrorCallback('attach-failed') @@ -327,25 +364,6 @@ export class AppClient extends BaseClient { return this.__profileLoader.getProfile(addr) } - async getEncryptorPublicKeys(): Promise { - return await this.__encryptor.exportPublicKey() - } - - /** - * Return if current player is in game. - */ - isInGame(): boolean { - try { - const playerId = this.addrToId(this.__playerAddr) - if (this.__gameContext.players.find(p => p.id === playerId) !== undefined) { - return true - } - return false - } catch (e) { - return false - } - } - makeSubGameAddr(gameId: number): string { return `${this.__gameAddr}:${gameId}` } @@ -353,7 +371,7 @@ export class AppClient extends BaseClient { // Miscellaneous -export async function getGameBundle(transport: ITransport, storage: IStorage | undefined, bundleAddr: string): Promise { +export async function getGameBundle(transport: ITransport, storage: IStorage | undefined, bundleAddr: string): Promise { let gameBundle = undefined if (storage) { @@ -374,7 +392,7 @@ export async function getGameBundle(transport: ITransport, storage: IStora return gameBundle } -export function makeGameInfo(gameAccount: GameAccount, token: Token): GameInfo { +export function makeGameInfo(gameAccount: IGameAccount, token: IToken): GameInfo { const info: GameInfo = { gameAddr: gameAccount.addr, title: gameAccount.title, diff --git a/packages/sdk-core/src/app-helper.ts b/packages/sdk-core/src/app-helper.ts index 23f2368..5f3a69b 100644 --- a/packages/sdk-core/src/app-helper.ts +++ b/packages/sdk-core/src/app-helper.ts @@ -1,4 +1,4 @@ -import { GameAccount, Nft, Token, TokenBalance, RecipientAccount } from './accounts' +import { IGameAccount, INft, IToken, TokenBalance, IRecipientAccount } from './accounts' import { CheckpointOffChain } from './checkpoint' import { SdkError } from './error' import { ResponseHandle, ResponseStream } from './response' @@ -34,7 +34,7 @@ import { } from './transport' import { PlayerProfileWithPfp } from './types' import { getLatestCheckpoints } from './connection' -import { IPublicKeyRaws } from './encryptor' +import { generateCredentials, IPublicKeyRaws } from './encryptor' import { IStorage } from './storage' export type AppHelperInitOpts = { @@ -50,8 +50,21 @@ export type JoinOpts = { addr: string amount: bigint position?: number - keys: IPublicKeyRaws - createProfileIfNeeded?: boolean +} + +export type CreateProfileOpts = { + nick: string + pfp?: string + secret: Uint8Array +} + +export type UpdateProfileOpts = { + nick: string + pfp: string | undefined + secrets: Uint8Array + public_ec: Uint8Array + public_rsa: Uint8Array + salt: Uint8Array } export type DepositOpts = { @@ -107,8 +120,8 @@ export class AppHelper { /** * Adds a new slot to an existing recipient account. * - * @param wallet - The wallet adapter to sign the transaction. - * @param params - The parameters for adding the slot. + * @param wallet - Wallet adapter to sign the transaction. + * @param params - Parameters for adding the slot. * @returns A ResponseStream that emits the status of the transaction. */ addRecipientSlot( @@ -120,13 +133,35 @@ export class AppHelper { return response.stream() } + /** + * Prepare the secret for credentials. + * + * Usually it requires the user to sign a message with the wallet if it doesn't exist. + * This secret is required in profile creation. + * + * @param wallet - Wallet adapter to sign the message. + * @param storage - Storage to save the secret. + * @return Secret generated + */ + async generateSecret(wallet: W, storage: IStorage): Promise { + const walletAddr = this.__transport.walletAddr(wallet) + const secret = await storage.getSecret(walletAddr) + if (!secret) { + const originalSecret = await this.__transport.getCredentialOriginSecret(wallet) + storage.cacheSecret(walletAddr, originalSecret) + return originalSecret + } else { + return secret + } + } + /** * Get the game account by game address. * - * @param addr - The address of game account + * @param addr - Address of game account * @returns An object of GameAccount or undefined when not found */ - async getGame(addr: string): Promise { + async getGame(addr: string): Promise { return await this.__transport.getGameAccount(addr) } @@ -203,12 +238,12 @@ export class AppHelper { */ createProfile( wallet: W, - nick: string, - pfp?: string + params: CreateProfileOpts, ): ResponseStream { const response = new ResponseHandle() - this.__transport.createPlayerProfile(wallet, { nick, pfp }, response) + + const resp = this.__transport.createPlayerProfile(wallet, params, response) return response.stream() } @@ -260,7 +295,7 @@ export class AppHelper { * @param gameAccounts * @returns The latest checkpoint from transactor or undefined when it's not available. */ - async fetchLatestCheckpoints(gameAccounts: GameAccount[]): Promise<(CheckpointOffChain | undefined)[]> { + async fetchLatestCheckpoints(gameAccounts: IGameAccount[]): Promise<(CheckpointOffChain | undefined)[]> { const endpointToAddrs = new Map() const addrToGameAccountIndex = new Map() @@ -340,9 +375,9 @@ export class AppHelper { if (profile === undefined) return undefined if (profile.pfp !== undefined) { const pfp = await this.getNft(profile.pfp, storage) - return { nick: profile.nick, addr: profile.addr, pfp } + return { nick: profile.nick, addr: profile.addr, pfp, credentials: profile.credentials } } else { - return { nick: profile.nick, addr: profile.addr, pfp: undefined } + return { nick: profile.nick, addr: profile.addr, pfp: undefined, credentials: profile.credentials } } } @@ -352,7 +387,7 @@ export class AppHelper { * @param registrationAddrs - The addresses of registration accounts * @return A list of games */ - async listGames(registrationAddrs: string[]): Promise { + async listGames(registrationAddrs: string[]): Promise { return ( await Promise.all( registrationAddrs.map(async regAddr => { @@ -374,11 +409,11 @@ export class AppHelper { * * @return A list of token info. */ - async listTokens(tokenAddrs: string[], storage?: IStorage): Promise { + async listTokens(tokenAddrs: string[], storage?: IStorage): Promise { if (!storage) { return await this.__transport.listTokens(tokenAddrs) } else { - let tokens: Token[] = [] + let tokens: IToken[] = [] let cachedTokens = await storage.getTokens(tokenAddrs) for (const token of cachedTokens) { @@ -388,7 +423,9 @@ export class AppHelper { } const cachedAddrs = tokens.map(token => token.addr) const addrsToFetch = tokenAddrs.filter(addr => !cachedAddrs.includes(addr)) + const fetchedTokens = await this.__transport.listTokens(addrsToFetch) + storage.cacheTokens(fetchedTokens) tokens.push(...fetchedTokens) return tokens @@ -412,7 +449,7 @@ export class AppHelper { * * @return A list of nfts. */ - async listNfts(walletAddr: string, collection: string | undefined = undefined): Promise { + async listNfts(walletAddr: string, collection: string | undefined = undefined): Promise { const nfts = await this.__transport.listNfts(walletAddr) if (collection === undefined) { return nfts @@ -427,7 +464,7 @@ export class AppHelper { * @param addr - The address of NFT * @param storage - The storage for caching */ - async getNft(addr: string, storage?: IStorage): Promise { + async getNft(addr: string, storage?: IStorage): Promise { if (!storage) { return await this.__transport.getNft(addr) } else { @@ -455,16 +492,16 @@ export class AppHelper { return response.stream() } - async getRecipient(recipientAddr: string): Promise { + async getRecipient(recipientAddr: string): Promise { return await this.__transport.getRecipient(recipientAddr) } /** - * Initiates a join request for a game session. It exports a public key and - * sends a join request with required parameters like game address, amount, - * position, and whether to create a profile if needed. Returns a stream to - * handle the response of the join operation, which can either be a success - * (JoinResponse) or an error (JoinError). + * Initiates a join request for a game session. Send a join + * request with required parameters like game address, amount, + * position, and whether to create a profile if needed. Returns a + * stream to handle the response of the join operation, which can + * either be a success (JoinResponse) or an error (JoinError). * * @param {JoinOpts} params - Options and parameters to configure the join request. * @returns {ResponseStream} A stream to handle the @@ -479,8 +516,6 @@ export class AppHelper { gameAddr: params.addr, amount: params.amount, position: params.position || 0, - verifyKey: params.keys.ec, - createProfileIfNeeded: params.createProfileIfNeeded, }, response ) @@ -488,6 +523,7 @@ export class AppHelper { return response.stream() } + deposit(wallet: W, params: DepositOpts): ResponseStream { const response = new ResponseHandle() @@ -513,8 +549,8 @@ export class AppHelper { * @param recipientAddr | recipientAccount - The address of a recipient account. */ previewClaim(wallet: W, recipientAddr: string): Promise - previewClaim(wallet: W, recipientAccount: RecipientAccount): Promise - async previewClaim(wallet: W, recipient: RecipientAccount | string): Promise { + previewClaim(wallet: W, recipientAccount: IRecipientAccount): Promise + async previewClaim(wallet: W, recipient: IRecipientAccount | string): Promise { try { if (typeof recipient === 'string') { const r = await this.__transport.getRecipient(recipient) @@ -534,7 +570,7 @@ export class AppHelper { totalClaimed += share.claimAmount totalWeights += share.weights - if (share.owner.kind === 'assigned' && share.owner.addr === this.__transport.walletAddr(wallet)) { + if (share.owner.kind === 'Assigned' && share.owner.addr === this.__transport.walletAddr(wallet)) { weights += share.weights claimed += share.claimAmount } diff --git a/packages/sdk-core/src/base-client.ts b/packages/sdk-core/src/base-client.ts index 1d90bd1..02408ea 100644 --- a/packages/sdk-core/src/base-client.ts +++ b/packages/sdk-core/src/base-client.ts @@ -8,12 +8,15 @@ import { } from './connection' import { EventEffects, GameContext } from './game-context' import { GameContextSnapshot } from './game-context-snapshot' +import { Credentials } from './credentials' import { ITransport } from './transport' import { IStorage } from './storage' import { Handler } from './handler' -import { IEncryptor, sha256String } from './encryptor' -import { GameAccount } from './accounts' +import { IEncryptor } from './encryptor' +import { IGameAccount } from './accounts' +import { sha256String } from './utils' import { Client } from './client' +import { CLIENT_MODES } from './client-mode' import { Custom, GameEvent, ICustomEvent } from './events' import { DecryptionCache } from './decryption-cache' import { @@ -38,6 +41,7 @@ import { import { InitAccount } from './init-account' import { CheckpointOnChain } from './checkpoint' import { SdkError } from './error' +import { Node } from './node' import { IProfileLoader } from './profile-loader' const MAX_RETRIES = 3 @@ -55,6 +59,7 @@ export type BaseClientCtorOpts = { client: Client transport: ITransport encryptor: IEncryptor + storage: IStorage profileLoader: IProfileLoader connection: IConnection gameContext: GameContext @@ -78,8 +83,8 @@ export class BaseClient { __handler: Handler __client: Client __transport: ITransport - __storage: IStorage | undefined __connection: IConnection + __storage: IStorage __gameContext: GameContext __onEvent: EventCallbackFunction __onMessage?: MessageCallbackFunction @@ -106,6 +111,7 @@ export class BaseClient { this.__client = opts.client this.__transport = opts.transport this.__connection = opts.connection + this.__storage = opts.storage this.__gameContext = opts.gameContext this.__onEvent = opts.onEvent this.__onMessage = opts.onMessage @@ -229,18 +235,6 @@ export class BaseClient { } } - async __attachGameWithRetry() { - for (let i = 0; i < 10; i++) { - const resp = await this.__client.attachGame() - if (resp === 'success') { - break - } else { - console.warn(this.__logPrefix + 'Game is not ready, try again after 2 second.') - await new Promise(r => setTimeout(r, 2000)) - } - } - } - __invokeErrorCallback(err: ErrorKind, arg?: any) { if (this.__onError) { this.__onError(err, arg) @@ -255,7 +249,7 @@ export class BaseClient { this.__onEvent(snapshot, state, event, options) } - async __getGameAccount(): Promise { + async __getGameAccount(): Promise { let retries = 0 while (true) { if (retries === MAX_RETRIES) { @@ -329,10 +323,10 @@ export class BaseClient { // For log group try { console.info('Event:', event) + console.info('Game context:', this.__gameContext) this.__gameContext.setTimestamp(timestamp) effects = await this.__handler.handleEvent(this.__gameContext, event) state = this.__gameContext.handlerState - await this.__checkStateSha(stateSha, 'event-state-sha-mismatch') } catch (e: any) { console.error(this.__logPrefix, e) @@ -354,22 +348,103 @@ export class BaseClient { } } + async __loadNodeCredentials(node: Node) { + const addr = node.addr + + let credentials; + + if (node.mode === 0) { // player + // XXX preload this profile? + const profile = await this.__profileLoader.getProfile(addr) + + if (profile === undefined) { + throw new Error(`Profile account not found for ${addr}`) + } + + console.debug(`Player ${addr} credentials:`, profile.credentials) + credentials = Credentials.deserialize(profile.credentials) + } else { + const server = await this.__transport.getServerAccount(addr) + + if (server === undefined) { + throw new Error(`Server account not found for ${addr}`) + } + + console.debug(`Server ${addr} credentials:`, server.credentials) + credentials = Credentials.deserialize(server.credentials) + } + + this.__gameContext.addNode(node.addr, node.id, CLIENT_MODES[node.mode]) + + if (this.playerAddr === node.addr) { + const secret = await this.__storage.getSecret(addr) + if (!secret) { + throw new Error('Secret not found in storage, do `generateCredentials` before any game') + } + await this.__encryptor.importCredentials(secret, addr, credentials) + } else { + await this.__encryptor.importPublicCredentials(addr, credentials) + } + } + + + async __loadServerCredentials(addr: string, id: bigint, transactor_addr: string) { + const server = await this.__transport.getServerAccount(addr) + + if (server === undefined) { + throw new Error(`Server account not found for ${addr}`) + } + + const credentials = Credentials.deserialize(server.credentials) + const mode = addr === transactor_addr ? 'Transactor' : 'Validator' + + this.__gameContext.addNode(addr, id, mode) + + await this.__encryptor.importPublicCredentials(addr, credentials) + + console.info(`Load server for ${addr}, mode: ${mode}`) + } + + async __loadPlayerCredentials(addr: string, id: bigint) { + const profile = await this.__profileLoader.getProfile(addr) + + if (profile === undefined) { + throw new Error(`Profile account not found for ${addr}`) + } + + const credentials = Credentials.deserialize(profile.credentials) + const mode = 'Player' + + this.__gameContext.addNode(addr, id, mode) + + if (this.playerAddr === addr) { + const secret = await this.__storage.getSecret(addr) + if (!secret) { + throw new Error('Secret not found in storage, do `generateCredentials` before any game') + } + await this.__encryptor.importCredentials(secret, addr, credentials) + } else { + await this.__encryptor.importPublicCredentials(addr, credentials) + } + + console.info(`Load profile for ${addr}`) + } + async __handleSync(frame: BroadcastFrameSync) { console.group(`${this.__logPrefix}Receive sync broadcast`, frame) try { + await this.__profileLoader.load(frame.newPlayers.map(p => p.addr)) + for (const node of frame.newServers) { - this.__gameContext.addNode( - node.addr, - node.accessVersion, - node.addr === frame.transactor_addr ? 'transactor' : 'validator' - ) + await this.__loadServerCredentials(node.addr, node.accessVersion, frame.transactor_addr) } + for (const node of frame.newPlayers) { - this.__gameContext.addNode(node.addr, node.accessVersion, 'player') - console.info('Load profile for:', node.addr) + await this.__loadPlayerCredentials(node.addr, node.accessVersion) } - this.__profileLoader.load(frame.newPlayers.map(p => p.addr)) + this.__gameContext.setAccessVersion(frame.accessVersion) + } finally { console.groupEnd() } @@ -402,27 +477,38 @@ export class BaseClient { await this.__handleEvent(frame) } else if (frame instanceof BroadcastFrameBacklogs) { console.group(`${this.__logPrefix}Receive event backlogs`, frame) + console.debug(`Game ID = ${this.__gameId}`) - // TODO, some special handling for subgame - if (this.__gameId !== 0) { - const versionedData = frame.checkpointOffChain?.data.get(this.__gameId) - if (versionedData === undefined) { - console.warn('Invalid versioned data', versionedData) - throw new Error('Missing checkpoint, mostly a bug') - } - this.__gameContext.checkpoint.initVersionedData(versionedData) - this.__gameContext.setHandlerState(versionedData.data) - this.__gameContext.versions = versionedData.versions - this.__gameContext.stateSha = await sha256String(versionedData.data) - } else { - const handlerState = this.__gameContext.checkpoint.getData(0) - if (handlerState === undefined) { - throw SdkError.malformedCheckpoint() - } - this.__gameContext.setHandlerState(handlerState) - this.__gameContext.stateSha = await sha256String(handlerState) + const nodes: Node[] = frame.checkpointOffChain?.sharedData?.nodes || [] + + console.debug('Load node information:', nodes) + + const nodePlayerAddrs = nodes.filter(n => n.mode === 0).map(n => n.addr) + await this.__profileLoader.load(nodePlayerAddrs) + + for (const node of nodes) { + await this.__loadNodeCredentials(node) + } + + // TODO, remove the unnecessary part of the message. + // + // The versioned data for current game is always the rootData. + + console.log(frame) + console.log(frame.checkpointOffChain) + console.log(frame.checkpointOffChain?.rootData) + + let versionedData = frame.checkpointOffChain?.rootData + + if (versionedData === undefined) { + console.warn('Invalid versioned data', versionedData) + throw SdkError.missingCheckpoint() } + this.__gameContext.versionedData = versionedData + this.__gameContext.setHandlerState(versionedData.handlerState) + this.__gameContext.versions = versionedData.versions + await this.__checkStateSha(frame.stateSha, 'checkpoint-state-sha-mismatch') try { @@ -443,12 +529,17 @@ export class BaseClient { } else { console.warn('Callback onReady is not provided.') } + } catch (e) { + console.error(e) } finally { console.groupEnd() } } } + /** + * Connect to the transactor. + */ __connect() { this.__connection.connect( new ConnectParams({ diff --git a/packages/sdk-core/src/broadcast-frames.ts b/packages/sdk-core/src/broadcast-frames.ts index b1d99b5..94941cf 100644 --- a/packages/sdk-core/src/broadcast-frames.ts +++ b/packages/sdk-core/src/broadcast-frames.ts @@ -13,8 +13,6 @@ export class BroadcastPlayerJoin { readonly position!: number @field('u64') readonly accessVersion!: bigint - @field('string') - readonly verifyKey!: string constructor(fields: Fields) { Object.assign(this, fields) } @@ -27,8 +25,6 @@ export class BroadcastServerJoin { readonly endpoint!: string @field('u64') readonly accessVersion!: bigint - @field('string') - readonly verifyKey!: string constructor(fields: Fields) { Object.assign(this, fields) } diff --git a/packages/sdk-core/src/checkpoint.ts b/packages/sdk-core/src/checkpoint.ts index cb92bea..0179842 100644 --- a/packages/sdk-core/src/checkpoint.ts +++ b/packages/sdk-core/src/checkpoint.ts @@ -1,24 +1,14 @@ -import { array, deserialize, field, map, option, struct, enums } from '@race-foundation/borsh' -import { sha256 } from './encryptor' +import { array, deserialize, serialize, field, map, option, struct, enums } from '@race-foundation/borsh' +import { sha256 } from './utils' import { Fields } from './types' import { GameEvent } from './events' -import { EmitBridgeEvent, SubGame } from './effect' - -export class Versions { - @field('u64') - accessVersion!: bigint - - @field('u64') - settleVersion!: bigint - - static default(): Versions { - return new Versions({ accessVersion: 0n, settleVersion: 0n }) - } - - constructor(fields: Fields) { - Object.assign(this, fields) - } -} +import { EmitBridgeEvent, LaunchSubGame, PlayerBalance } from './effect' +import { SharedData } from './shared-data' +import { VersionedData } from './versioned-data' +import { GameSpec } from './game-spec' +import { Versions } from './versions' +import { Node, INodeStatus } from './node' +import { ClientMode } from './client-mode' export class CheckpointOnChain { @field('u8-array') @@ -39,79 +29,35 @@ export class CheckpointOnChain { } } -export class GameSpec { - @field('string') - readonly gameAddr!: string - - @field('usize') - readonly gameId!: number - - @field('string') - readonly bundleAddr!: string - - @field('u16') - readonly maxPlayers!: number - - constructor(fields: Fields) { - Object.assign(this, fields) - } -} +export class CheckpointOffChain { + @field(struct(VersionedData)) + rootData!: VersionedData -export class DispatchEvent { - @field('u64') - timeout!: bigint + @field(struct(SharedData)) + sharedData!: SharedData - @field(enums(GameEvent)) - event!: GameEvent + @field(array('u8-array')) + proofs!: Uint8Array[] - constructor(fields: Fields) { + constructor(fields: any) { Object.assign(this, fields) } -} - -export class VersionedData { - @field('usize') - id!: number - - @field(struct(Versions)) - versions!: Versions - - @field('u8-array') - data!: Uint8Array - - @field('u8-array') - sha!: Uint8Array - - @field(struct(GameSpec)) - spec!: GameSpec - - @field(option(struct(DispatchEvent))) - dispatch!: DispatchEvent | undefined - @field(array(struct(EmitBridgeEvent))) - bridgeEvents!: EmitBridgeEvent[] - - constructor(fields: any) { - Object.assign(this, fields) + static deserialize(raw: Uint8Array): CheckpointOffChain { + return deserialize(CheckpointOffChain, raw) } } -export class CheckpointOffChain { - @field(map('usize', struct(VersionedData))) - data!: Map - - @field(map('usize', 'u8-array')) - proofs!: Map - - @field(array(struct(SubGame))) - launchSubgames!: SubGame[] +export class CheckpointOffChainOrNull { + @field(option(struct(CheckpointOffChain))) + checkpoint!: CheckpointOffChain | undefined constructor(fields: any) { Object.assign(this, fields) } - static deserialize(raw: Uint8Array): CheckpointOffChain { - return deserialize(CheckpointOffChain, raw) + static deserialize(raw: Uint8Array): CheckpointOffChainOrNull { + return deserialize(CheckpointOffChainOrNull, raw) } } @@ -136,14 +82,14 @@ export class Checkpoint { @field('u64') accessVersion!: bigint - @field(map('usize', struct(VersionedData))) - data!: Map + @field(struct(VersionedData)) + rootData!: VersionedData - @field(map('usize', 'u8-array')) - proofs!: Map + @field(struct(SharedData)) + sharedData!: SharedData - @field(array(struct(SubGame))) - launchSubgames!: SubGame[] + @field(array('u8-array')) + proofs!: Uint8Array[] constructor(fields: any) { Object.assign(this, fields) @@ -156,10 +102,9 @@ export class Checkpoint { static fromParts(offchainPart: CheckpointOffChain, onchainPart: CheckpointOnChain): Checkpoint { let checkpoint = Checkpoint.default() checkpoint.proofs = offchainPart.proofs - checkpoint.data = offchainPart.data + checkpoint.rootData = offchainPart.rootData + checkpoint.sharedData = offchainPart.sharedData checkpoint.accessVersion = onchainPart.accessVersion - checkpoint.root = onchainPart.root - checkpoint.launchSubgames = offchainPart.launchSubgames return checkpoint } @@ -185,64 +130,18 @@ export class Checkpoint { } clone(): Checkpoint { - return new Checkpoint({ - accessVersion: this.accessVersion, - data: new Map(this.data.entries()), - }) - } - - getData(id: number): Uint8Array | undefined { - return this.data.get(id)?.data - } - - getSha(id: number): Uint8Array | undefined { - return this.data.get(id)?.sha + return structuredClone(this) } - setAccessVersion(accessVersion: bigint) { - this.accessVersion = accessVersion - } - - async initData(id: number, data: Uint8Array, spec: GameSpec) { - if (this.data.has(id)) { - throw new Error(`Checkpoint ${id} already exists`) - } - const sha = await sha256(data) - const versionedData = new VersionedData({ - id, - data, - spec, - sha, - versions: Versions.default(), - }) - this.data.set(id, versionedData) - this.updateRootAndProofs() - } - - async initVersionedData(versionedData: VersionedData) { - this.data.set(versionedData.id, versionedData) - } - - async setData(id: number, data: Uint8Array) { - const sha = await sha256(data) - const old = this.data.get(id) - if (old !== undefined) { - old.data = data - old.versions.settleVersion += 1n - old.sha = sha - } else { - throw new Error(`Checkpoint ${id} is missing`) - } - this.updateRootAndProofs() - } + updateRootAndProofs() {} +} - getVersionedData(id: number): VersionedData | undefined { - return this.data.get(id) - } +export class ContextCheckpoint { + rootData: VersionedData + sharedData: SharedData - containsVersionedData(id: number): boolean { - return this.data.has(id) + constructor(sharedData: SharedData, rootData: VersionedData) { + this.rootData = rootData + this.sharedData = sharedData } - - updateRootAndProofs() {} } diff --git a/packages/sdk-core/src/client-mode.ts b/packages/sdk-core/src/client-mode.ts new file mode 100644 index 0000000..8f7182e --- /dev/null +++ b/packages/sdk-core/src/client-mode.ts @@ -0,0 +1,4 @@ +import { UnionFromValues } from './types' + +export const CLIENT_MODES = ['Player', 'Transactor', 'Validator'] as const +export type ClientMode = UnionFromValues diff --git a/packages/sdk-core/src/client.ts b/packages/sdk-core/src/client.ts index 20c1079..f5c8ba5 100644 --- a/packages/sdk-core/src/client.ts +++ b/packages/sdk-core/src/client.ts @@ -1,4 +1,4 @@ -import { AttachGameParams, AttachResponse, IConnection, SubmitEventParams } from './connection' +import { IConnection, SubmitEventParams } from './connection' import { IEncryptor } from './encryptor' import { SecretState } from './secret-state' import { GameContext } from './game-context' @@ -38,16 +38,6 @@ export class Client { this.__secretState = new SecretState(encryptor) } - async attachGame(): Promise { - const key = await this.__encryptor.exportPublicKey(undefined) - return await this.__connection.attachGame( - new AttachGameParams({ - signer: this.__addr, - key, - }) - ) - } - async submitEvent(event: any): Promise { await this.__connection.submitEvent( new SubmitEventParams({ diff --git a/packages/sdk-core/src/connection.ts b/packages/sdk-core/src/connection.ts index 35faf71..9f9fd4f 100644 --- a/packages/sdk-core/src/connection.ts +++ b/packages/sdk-core/src/connection.ts @@ -3,7 +3,7 @@ import { GameEvent } from './events' import { deserialize, enums, field, serialize, struct } from '@race-foundation/borsh' import { arrayBufferToBase64, base64ToUint8Array } from './utils' import { BroadcastFrame } from './broadcast-frames' -import { CheckpointOffChain, CheckpointOffChainList } from './checkpoint' +import { CheckpointOffChain, CheckpointOffChainOrNull, CheckpointOffChainList } from './checkpoint' let __WebSocket_impl: (new (url: string | URL) => WebSocket) | undefined = undefined @@ -23,10 +23,7 @@ function createWebSocket(endpoint: string): WebSocket { export type ConnectionState = 'disconnected' | 'connected' | 'reconnected' | 'closed' -export type AttachResponse = 'success' | 'game-not-loaded' - type Method = - | 'attach_game' | 'submit_event' | 'exit_game' | 'subscribe_event' @@ -34,11 +31,11 @@ type Method = | 'get_state' | 'ping' | 'get_checkpoint' + | 'get_latest_checkpoint' | 'get_latest_checkpoints' interface IAttachGameParams { signer: string - key: PublicKeyRaws } interface ISubscribeEventParams { @@ -64,11 +61,8 @@ export type ConnectionSubscription = AsyncGenerator export class AttachGameParams { @field('string') signer: string - @field(struct(PublicKeyRaws)) - key: PublicKeyRaws constructor(fields: IAttachGameParams) { - this.key = fields.key this.signer = fields.signer } } @@ -110,8 +104,6 @@ export class GetCheckpointParams { } export interface IConnection { - attachGame(params: AttachGameParams): Promise - getState(): Promise getCheckpoint(params: GetCheckpointParams): Promise @@ -210,22 +202,19 @@ export class Connection implements IConnection { } } - async attachGame(params: AttachGameParams): Promise { - const req = makeReqNoSig(this.target, 'attach_game', params) - const resp: any = await this.requestXhr(req) - if (resp.error !== undefined) { - return 'game-not-loaded' - } else { - return 'success' - } - } - async getState(): Promise { const req = makeReqNoSig(this.target, 'get_state', {}) const resp: { result: string } = await this.requestXhr(req) return Uint8Array.from(JSON.parse(resp.result)) } + async getLatestCheckpoint(): Promise { + const req = makeReqNoSig(this.target, 'get_latest_checkpoint', {}) + const resp: { result: number[] | null } = await this.requestXhr(req) + if (!resp.result) return undefined + return CheckpointOffChainOrNull.deserialize(Uint8Array.from(resp.result)).checkpoint + } + async getCheckpoint(params: GetCheckpointParams): Promise { const req = makeReqNoSig(this.target, 'get_checkpoint', params) const resp: { result: number[] | null } = await this.requestXhr(req) @@ -238,7 +227,8 @@ export class Connection implements IConnection { const req = await this.makeReq(this.target, 'submit_event', params) await this.requestXhr(req) return undefined - } catch (_: any) { + } catch (e: any) { + console.warn('Error in sending request:', e) return 'disconnected' } } @@ -248,7 +238,8 @@ export class Connection implements IConnection { const req = await this.makeReq(this.target, 'submit_message', params) await this.requestXhr(req) return undefined - } catch (_: any) { + } catch (e: any) { + console.warn('Error in sending request:', e) return 'disconnected' } } @@ -262,6 +253,7 @@ export class Connection implements IConnection { async exitGame(params: ExitGameParams): Promise { const req = await this.makeReq(this.target, 'exit_game', {}) + console.debug('exitGame:', req) await this.requestXhr(req) if (!params.keepConnection) this.disconnect() } @@ -341,6 +333,7 @@ export class Connection implements IConnection { }) if (resp.ok) { const ret = await resp.json() + console.debug("Xhr response:", ret) return ret } else { throw Error('Transactor request failed:' + resp.json()) @@ -397,7 +390,7 @@ function makeReqAddrs(method: Method, addrs: string[]): string { export async function getLatestCheckpoints( transactorEndpoint: string, - addrs: string[] + addrs: string[], ): Promise<(CheckpointOffChain | undefined)[]> { const req = makeReqAddrs('get_latest_checkpoints', addrs) try { diff --git a/packages/sdk-core/src/credentials.ts b/packages/sdk-core/src/credentials.ts new file mode 100644 index 0000000..0f20430 --- /dev/null +++ b/packages/sdk-core/src/credentials.ts @@ -0,0 +1,45 @@ +import { field, serialize, deserialize } from '@race-foundation/borsh' +import { Fields } from './types' +import { hexToBuffer } from './utils' + +export const RAW_CREDENTIALS_MESSAGE = 'This message is required as credentials for gameplay.' +export const CREDENTIALS_MESSAGE = new TextEncoder().encode(RAW_CREDENTIALS_MESSAGE) + +/** + * The credentials for game play. + * + * EC (ED25519) keypair is used to sign and verify the message. + * It prevents forged sender. + * + * RSA keypair is used to encrypt and decrypt message. + * It ensures the security of bilateral message (sharing secrets). + * + * Private keys in this structure are encrypted with AES, which is + * derived with a wallet-signed message and salt. + */ +export class Credentials { + @field('u8-array') + ecPublic!: Uint8Array + @field('u8-array') + rsaPublic!: Uint8Array + @field('u8-array') + salt!: Uint8Array + @field('u8-array') + iv!: Uint8Array + @field('u8-array') + ecPrivateEnc!: Uint8Array + @field('u8-array') + rsaPrivateEnc!: Uint8Array + + constructor(fields: Fields) { + Object.assign(this, fields) + } + + static deserialize(data: Uint8Array): Credentials { + return deserialize(Credentials, data) + } + + serialize(): Uint8Array { + return serialize(this) + } +} diff --git a/packages/sdk-core/src/dispatch-event.ts b/packages/sdk-core/src/dispatch-event.ts new file mode 100644 index 0000000..0f1d2f5 --- /dev/null +++ b/packages/sdk-core/src/dispatch-event.ts @@ -0,0 +1,15 @@ +import { field, enums } from '@race-foundation/borsh' +import { GameEvent } from './events' +import { Fields } from './types' + +export class DispatchEvent { + @field('u64') + timeout!: bigint + + @field(enums(GameEvent)) + event!: GameEvent + + constructor(fields: Fields) { + Object.assign(this, fields) + } +} diff --git a/packages/sdk-core/src/effect.ts b/packages/sdk-core/src/effect.ts index 58c0b8a..78aad90 100644 --- a/packages/sdk-core/src/effect.ts +++ b/packages/sdk-core/src/effect.ts @@ -134,14 +134,14 @@ export class ActionTimeout { } } -export class SubGame { +export class LaunchSubGame { @field('usize') gameId!: number @field('string') bundleAddr!: string @field(struct(InitAccount)) initAccount!: InitAccount - constructor(fields: Fields) { + constructor(fields: Fields) { Object.assign(this, fields) } } @@ -217,8 +217,8 @@ export class Effect { error: HandleError | undefined @field(option(struct(Transfer))) transfer!: Transfer | undefined - @field(array(struct(SubGame))) - launchSubGames!: SubGame[] + @field(array(struct(LaunchSubGame))) + launchSubGames!: LaunchSubGame[] @field(array(struct(EmitBridgeEvent))) bridgeEvents!: EmitBridgeEvent[] @field('bool') @@ -271,7 +271,7 @@ export class Effect { const handlerState = context.handlerState const error = undefined const transfer: Transfer | undefined = undefined - const launchSubGames: SubGame[] = [] + const launchSubGames: LaunchSubGame[] = [] const bridgeEvents: EmitBridgeEvent[] = [] const entryLock = undefined const reset = false diff --git a/packages/sdk-core/src/encryptor.ts b/packages/sdk-core/src/encryptor.ts index cf0aab7..472b5f6 100644 --- a/packages/sdk-core/src/encryptor.ts +++ b/packages/sdk-core/src/encryptor.ts @@ -1,3 +1,9 @@ +/** + * Encryptor handles the secrets that used for encrypting game data. + * + * The public keys and secrets can be packaged and exported as NodeKeys. + */ + import { SdkError } from './error' import { Secret, Ciphertext } from './types' import { field } from '@race-foundation/borsh' @@ -5,6 +11,7 @@ import { base64ToArrayBuffer, arrayBufferToBase64 } from './utils' import { Chacha20 } from 'ts-chacha20' import { subtle } from './crypto' import { IStorage } from './storage' +import { Credentials } from './credentials' export const aesContentIv = Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) @@ -170,7 +177,7 @@ export async function generateRsaKeypair(): Promise { return await subtle().generateKey( { name: 'RSA-OAEP', - modulusLength: 1024, + modulusLength: 512, publicExponent: publicExponent, hash: 'SHA-256', }, @@ -179,6 +186,7 @@ export async function generateRsaKeypair(): Promise { ) } + export function generateChacha20(): Uint8Array { const arr = new Uint8Array(32) crypto.getRandomValues(arr) @@ -218,15 +226,101 @@ export class Signature { } } +async function deriveKey(origin: Uint8Array, salt: Uint8Array): Promise { + const rawKey = await crypto.subtle.importKey( + 'raw', + origin, + { name: 'PBKDF2' }, + false, + [ 'deriveKey' ], + ) + + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array(salt), + iterations: 100000, + hash: 'SHA-256', + }, + rawKey, + { name: 'AES-GCM', length: 256 }, + true, + [ 'encrypt', 'decrypt' ] + ) + + return derivedKey +} + +/** + * Generate a new credentials. + * + * @param originalSecret a signature signed by user's wallet + */ +export async function generateCredentials(originalSecret: Uint8Array): Promise { + // Generate the keys + const rsaKeypair = await generateRsaKeypair() + const ecKeypair = await generateEcKeypair() + + // Use X25519 to derive a key from originalSecret. + const salt = crypto.getRandomValues(new Uint8Array(16)) + const derivedKey = await deriveKey(originalSecret, salt) + + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const ecPublic = await subtle().exportKey('spki', ecKeypair.publicKey) + const rsaPublic = await subtle().exportKey('spki', rsaKeypair.publicKey) + + const ecPrivate = await subtle().exportKey('pkcs8', ecKeypair.privateKey) + const rsaPrivate = await subtle().exportKey('pkcs8', rsaKeypair.privateKey) + + const ecPrivateEnc = await subtle().encrypt( + { + name: 'AES-GCM', + iv, + }, + derivedKey, + ecPrivate, + ) + const rsaPrivateEnc = await subtle().encrypt( + { + name: 'AES-GCM', + iv, + }, + derivedKey, + rsaPrivate, + ) + + return new Credentials({ + ecPublic: new Uint8Array(ecPublic), + rsaPublic: new Uint8Array(rsaPublic), + salt, + iv, + ecPrivateEnc: new Uint8Array(ecPrivateEnc), + rsaPrivateEnc: new Uint8Array(rsaPrivateEnc), + }) +} + + + /** * Encryptor * Use RSA and ChaCha20(AES-CTR) for random secrets encryption. */ export interface IEncryptor { - addPublicKey(addr: string, pubkeys: IPublicKeyRaws): Promise + /** + * Import a credentials of current node. + */ + importCredentials(originalSecret: Uint8Array, addr: string, credentials: Credentials): Promise + + /** + * Import a credentials with only its public part. + */ + importPublicCredentials(addr: string, credentials: Credentials): Promise - exportPublicKey(addr?: string): Promise + // addPublicKey(addr: string, pubkeys: IPublicKeyRaws): Promise + + // exportPublicKey(addr?: string): Promise decryptRsa(text: Uint8Array): Promise @@ -285,15 +379,76 @@ class NodePublicKey implements INodePublicKey { } export class Encryptor implements IEncryptor { - readonly #privateKey: INodePrivateKey - readonly #publicKeys: Map + #privateKey: INodePrivateKey | undefined + #publicKeys: Map - constructor(priv: INodePrivateKey) { - this.#privateKey = priv + constructor() { + this.#privateKey = undefined this.#publicKeys = new Map() } + async importCredentials(originalSecret: Uint8Array, addr: string, credentials: Credentials): Promise { + + console.debug(`Import credentials for node: ${addr}`) + + const { + ecPublic, rsaPublic, salt, iv, ecPrivateEnc, rsaPrivateEnc, + } = credentials + + const derivedKey = await deriveKey(originalSecret, salt) + + const ecPrivate = await subtle().decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + derivedKey, + ecPrivateEnc, + ); + + const rsaPrivate = await subtle().decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + derivedKey, + rsaPrivateEnc, + ); + + const ecPrivateKey = await subtle().importKey('pkcs8', ecPrivate, EC_PARAMS, true, ['sign']) + const ecPublicKey = await subtle().importKey('spki', ecPublic, EC_PARAMS, true, ['verify']) + const rsaPrivateKey = await subtle().importKey('pkcs8', rsaPrivate, RSA_PARAMS, true, ['decrypt']) + const rsaPublicKey = await subtle().importKey('spki', rsaPublic, RSA_PARAMS, true, ['encrypt']) + + console.debug(`Import credentials succeed: ${addr}`) + + this.#privateKey = { + rsa: { privateKey: rsaPrivateKey, publicKey: rsaPublicKey }, + ec: { privateKey: ecPrivateKey, publicKey: ecPublicKey }, + } + } + + async importPublicCredentials(addr: string, credentials: Credentials): Promise { + console.debug(`Import public credentials for node: ${addr}`) + console.debug('Credentials:', credentials) + + const { + ecPublic, rsaPublic, + } = credentials + + const ecPublicKey = await subtle().importKey('spki', ecPublic, EC_PARAMS, true, ['verify']) + const rsaPublicKey = await subtle().importKey('spki', rsaPublic, RSA_PARAMS, true, ['encrypt']) + + console.debug(`Import public credentials succeed: ${addr}`) + + this.#publicKeys.set(addr, { + rsa: rsaPublicKey, + ec: ecPublicKey, + }) + } + async decryptRsa(text: Uint8Array): Promise { + if (!this.#privateKey) throw new Error('No credential available for RSA decryption') return await decryptRsa(this.#privateKey.rsa.privateKey, text) } @@ -325,6 +480,7 @@ export class Encryptor implements IEncryptor { } async signRaw(message: Uint8Array): Promise { + if (!this.#privateKey) throw new Error('No credential available for EC sign') return await signEc(this.#privateKey.ec.privateKey, message) } @@ -340,6 +496,7 @@ export class Encryptor implements IEncryptor { async sign(message: Uint8Array, signer: string): Promise { const timestamp = BigInt(new Date().getTime()) const buf = this.makeSignMessage(message, timestamp) + const signature = await this.signRaw(buf) return new Signature({ timestamp, @@ -358,79 +515,21 @@ export class Encryptor implements IEncryptor { return await verifyEc(ecPublicKey, signature.signature, buf) } - async exportKeys(playerAddr: string): Promise { - return { - playerAddr, - ec: await this.exportEcKeys(), - rsa: await this.exportRsaKeys(), - } - } - - static async create(playerAddr: string, storage?: IStorage): Promise { - if (storage) { - const imported = await this.importFromStorage(playerAddr, storage) - if (imported !== undefined) { - return imported - } - } - const rsaKeypair = await generateRsaKeypair() - const ecKeypair = await generateEcKeypair() - const encryptor = new Encryptor(new NodePrivateKey(rsaKeypair, ecKeypair)) - if (storage) { - encryptor.exportToStorage(playerAddr, storage) - } - return encryptor - } - - async exportToStorage(playerAddr: string, storage: IStorage) { - const ec = await this.exportEcKeys() - const rsa = await this.exportRsaKeys() - storage.cacheEncryptorKeys({ playerAddr, ec, rsa }) - } - - static async importFromStorage(playerAddr: string, storage: IStorage): Promise { - const keys = await storage.getEncryptorKeys(playerAddr) - if (!keys) { - return undefined - } - const ecKeypair = await importEc(keys.ec) - const rsaKeypair = await importRsa(keys.rsa) - return new Encryptor(new NodePrivateKey(rsaKeypair, ecKeypair)) - } - - async exportRsaKeys(): Promise<[string, string]> { - return await exportRsa(this.#privateKey.rsa) - } - - async exportEcKeys(): Promise<[string, string]> { - return await exportEc(this.#privateKey.ec) - } - async addPublicKey(addr: string, { rsa, ec }: IPublicKeyRaws): Promise { const rsa_ = await importRsaPublicKey(rsa) const ec_ = await importEcPublicKey(ec) this.#publicKeys.set(addr, new NodePublicKey(rsa_, ec_)) } - async exportPublicKey(addr?: string): Promise { - let rsa, ec - if (addr === undefined) { - rsa = this.#privateKey.rsa.publicKey - ec = this.#privateKey.ec.publicKey - } else { - const publicKeys = this.#publicKeys.get(addr) - if (publicKeys === undefined) { - throw SdkError.publicKeyNotFound(addr) - } - rsa = publicKeys.rsa - ec = publicKeys.ec - } - return new PublicKeyRaws({ - rsa: await exportRsaPublicKey(rsa), - ec: await exportEcPublicKey(ec), - }) - } - + /** + * Decrypt the cipertext with given secrets. + * + * @param ciphertextMap The mapping from item index to ciphertext. + * @param secretMap The mapping from item index to a list of secrets for decryption. + * @param validOptions The expected output of decryption. The decryption fails when it doesn't meet a valid option. + * + * @return The result of decryption, a mapping from item inedx to plain item content. + */ async decryptWithSecrets( ciphertextMap: Map, secretMap: Map, @@ -439,6 +538,7 @@ export class Encryptor implements IEncryptor { const res = new Map() for (const [idx, ciphertext] of ciphertextMap) { const secrets = secretMap.get(idx) + if (secrets === undefined) { throw new Error('Missing secrets') } else { @@ -453,14 +553,3 @@ export class Encryptor implements IEncryptor { return res } } - -export async function sha256(data: Uint8Array): Promise { - let hashBuffer = await subtle().digest('SHA-256', data) - return new Uint8Array(hashBuffer) -} - -export async function sha256String(data: Uint8Array): Promise { - return Array.from(await sha256(data)) - .map(byte => byte.toString(16).padStart(2, '0')) - .join('') -} diff --git a/packages/sdk-core/src/entry-type.ts b/packages/sdk-core/src/entry-type.ts new file mode 100644 index 0000000..41e7a8e --- /dev/null +++ b/packages/sdk-core/src/entry-type.ts @@ -0,0 +1,129 @@ +import { variant, field } from '@race-foundation/borsh' +import { IKind } from './types' + +export type EntryTypeKind = 'cash' | 'ticket' | 'gating' | 'disabled' + +// Unified interfaces. + +export type IEntryTypeKind = IKind + +export type IEntryTypeCash = { + readonly minDeposit: bigint + readonly maxDeposit: bigint +} & IEntryTypeKind<'cash'> + +export type IEntryTypeTicket = { + readonly amount: bigint +} & IEntryTypeKind<'ticket'> + +export type IEntryTypeGating = { + readonly collection: string +} & IEntryTypeKind<'gating'> + +export type IEntryTypeDisabled = {} & IEntryTypeKind<'disabled'> + +export type IEntryType = IEntryTypeCash | IEntryTypeTicket | IEntryTypeGating | IEntryTypeDisabled + +export interface IHasEntryTypeKind { + kind(): EntryTypeKind +} + +// Default implementations, we used it in checkpoint. + +export abstract class AEntryType implements IHasEntryTypeKind { + kind(): EntryTypeKind { + return 'disabled' + } + + static from(entryType: IEntryType): AEntryType { + switch (entryType.kind) { + case 'cash': + return new EntryTypeCash(entryType) + case 'ticket': + return new EntryTypeTicket(entryType) + case 'gating': + return new EntryTypeGating(entryType) + default: + return new EntryTypeDisabled({}) + } + } + + generalize(): IEntryType { + if (this instanceof EntryTypeCash) { + return { + kind: 'cash', + minDeposit: this.minDeposit, + maxDeposit: this.maxDeposit, + } + } else if (this instanceof EntryTypeTicket) { + return { + kind: 'ticket', + amount: this.amount, + } + } else if (this instanceof EntryTypeGating) { + return { + kind: 'gating', + collection: this.collection, + } + } else { + return { + kind: 'disabled', + } + } + } +} + +@variant(0) +export class EntryTypeCash extends AEntryType implements IHasEntryTypeKind { + @field('u64') + minDeposit!: bigint + @field('u64') + maxDeposit!: bigint + constructor(fields: any) { + super() + Object.assign(this, fields) + Object.setPrototypeOf(this, EntryTypeCash.prototype) + } + kind(): EntryTypeKind { + return 'cash' + } +} + +@variant(1) +export class EntryTypeTicket extends AEntryType implements IHasEntryTypeKind { + @field('u64') + amount!: bigint + constructor(fields: any) { + super() + Object.assign(this, fields) + Object.setPrototypeOf(this, EntryTypeTicket.prototype) + } + kind(): EntryTypeKind { + return 'ticket' + } +} + +@variant(2) +export class EntryTypeGating extends AEntryType implements IHasEntryTypeKind { + @field('string') + collection!: string + constructor(fields: any) { + super() + Object.assign(this, fields) + Object.setPrototypeOf(this, EntryTypeGating.prototype) + } + kind(): EntryTypeKind { + return 'gating' + } +} + +@variant(3) +export class EntryTypeDisabled extends AEntryType implements IHasEntryTypeKind { + constructor(_: any) { + super() + Object.setPrototypeOf(this, EntryTypeDisabled.prototype) + } + kind(): EntryTypeKind { + return 'disabled' + } +} diff --git a/packages/sdk-core/src/error.ts b/packages/sdk-core/src/error.ts index 878dd3e..410e6b5 100644 --- a/packages/sdk-core/src/error.ts +++ b/packages/sdk-core/src/error.ts @@ -21,6 +21,14 @@ export class NoEnoughPlayers extends HandleError { } @variant(2) +export class NoEnoughServers extends HandleError { + constructor(_: any) { + super() + this.message = 'No enough servers' + } +} + +@variant(3) export class InvalidPlayer extends HandleError { constructor(_: any) { super() @@ -28,7 +36,7 @@ export class InvalidPlayer extends HandleError { } } -@variant(3) +@variant(4) export class CantLeave extends HandleError { constructor(_: any) { super() @@ -36,7 +44,7 @@ export class CantLeave extends HandleError { } } -@variant(4) +@variant(5) export class InvalidAmount extends HandleError { constructor(_: any) { super() @@ -85,10 +93,10 @@ export class SerializationError extends HandleError { } @variant(10) -export class NoEnoughServers extends HandleError { +export class SubGameWithoutCheckpointError extends HandleError { constructor(_: any) { super() - this.message = 'No enough servers' + this.message = 'No checkpoint for sub game' } } @@ -102,6 +110,48 @@ export class InternalError extends HandleError { } } +@variant(12) +export class InvalidDepositError extends HandleError { + constructor(_: any) { + super() + this.message = `Invalid deposit` + } +} + +@variant(13) +export class DuplicatedBridgeEventTargetError extends HandleError { + constructor(_: any) { + super() + this.message = `Duplicate Bridge event target` + } +} + +@variant(14) +export class RandomnessNotRevealedError extends HandleError { + constructor(_: any) { + super() + this.message = `Randomness not revealed` + } +} + +@variant(15) +export class AnswerNotAvailableError extends HandleError { + constructor(_: any) { + super() + this.message = `Answer not available` + } +} + +@variant(16) +export class IoError extends HandleError { + @field('string') + message: string + constructor(fields: { message: string }) { + super() + this.message = fields.message + } +} + export class SdkError extends Error { constructor(message: string) { super(message) @@ -138,4 +188,8 @@ export class SdkError extends Error { static malformedCheckpoint() { return new SdkError('Malformed checkpoint') } + + static missingCheckpoint() { + return new SdkError('Missing checkpoint') + } } diff --git a/packages/sdk-core/src/game-context-snapshot.ts b/packages/sdk-core/src/game-context-snapshot.ts index e216351..988cbf2 100644 --- a/packages/sdk-core/src/game-context-snapshot.ts +++ b/packages/sdk-core/src/game-context-snapshot.ts @@ -1,11 +1,12 @@ import { DecryptionCache } from './decryption-cache' -import { GameContext, GameStatus, INode, NodeStatus } from './game-context' +import { GameContext, GameStatus } from './game-context' +import { INode, INodeStatus } from './node' import { RandomState } from './random-state' export class NodeSnapshot { readonly id: bigint readonly addr: string - readonly status: NodeStatus + readonly status: INodeStatus constructor(o: INode) { this.id = o.id @@ -25,7 +26,7 @@ export class GameContextSnapshot { readonly revealed: RevealedMap constructor(context: GameContext, decryptionCache: DecryptionCache) { - this.gameAddr = context.spec.gameAddr + this.gameAddr = context.gameSpec.gameAddr this.accessVersion = context.accessVersion this.settleVersion = context.settleVersion this.status = context.status diff --git a/packages/sdk-core/src/game-context.ts b/packages/sdk-core/src/game-context.ts index d604c1a..678d168 100644 --- a/packages/sdk-core/src/game-context.ts +++ b/packages/sdk-core/src/game-context.ts @@ -1,6 +1,13 @@ import { RandomState, RandomSpec } from './random-state' import { DecisionState } from './decision-state' -import { Checkpoint, GameSpec, Versions } from './checkpoint' +import { VersionedData } from './versioned-data' +import { SharedData, ISharedData } from './shared-data' +import { Versions } from './versions' +import { GameSpec } from './game-spec' +import { ClientMode } from './client-mode' +import { INode, INodeStatus } from './node' +import { Credentials } from './credentials' +import { EntryTypeDisabled } from './entry-type' import { ActionTimeout, Answer, @@ -17,37 +24,21 @@ import { InitAccount } from './init-account' import { Effect, EmitBridgeEvent, - SubGame, + LaunchSubGame, Settle, Transfer, PlayerBalance, BalanceChange, BalanceChangeAdd, } from './effect' -import { EntryType, GameAccount } from './accounts' +import { IGameAccount } from './accounts' +import { IEntryType } from './entry-type' import { Ciphertext, Digest, Fields } from './types' -import { sha256String } from './encryptor' +import { sha256String } from './utils' const OPERATION_TIMEOUT = 15_000n -export type NodeStatus = - | { - kind: 'pending' - accessVersion: bigint - } - | { kind: 'ready' } - | { kind: 'disconnected' } - -export type ClientMode = 'player' | 'transactor' | 'validator' - -export type GameStatus = 'idle' | 'running' | 'closed' - -export interface INode { - addr: string - id: bigint - mode: ClientMode - status: NodeStatus -} +export type GameStatus = 'Idle' | 'Running' | 'Closed' export interface DispatchEvent { timeout: bigint @@ -63,21 +54,13 @@ export interface EventEffects { settles: Settle[] transfer: Transfer | undefined checkpoint: Uint8Array | undefined - launchSubGames: SubGame[] + launchSubGames: LaunchSubGame[] bridgeEvents: EmitBridgeEvent[] startGame: boolean } -export class ContextPlayer { - id!: bigint - position!: number - constructor(fields: Fields) { - Object.assign(this, fields) - } -} - export class GameContext { - spec: GameSpec + gameSpec: GameSpec versions: Versions status: GameStatus nodes: INode[] @@ -87,154 +70,31 @@ export class GameContext { timestamp: bigint randomStates: RandomState[] decisionStates: DecisionState[] - checkpoint: Checkpoint - subGames: SubGame[] - initData: Uint8Array - players: ContextPlayer[] - entryType: EntryType - stateSha: string - - constructor(gameAccount: GameAccount, checkpoint: Checkpoint) { - if (checkpoint === undefined) { - throw new Error('Missing checkpoint') - } - const checkpointAccessVersion = gameAccount.checkpointOnChain?.accessVersion || 0 - const transactorAddr = gameAccount.transactorAddr - if (transactorAddr === undefined) { - throw new Error('Game not served') - } - let nodes: INode[] = [] - gameAccount.servers.forEach(s => - nodes.push({ - addr: s.addr, - id: s.accessVersion, - mode: s.addr === transactorAddr ? 'transactor' : 'validator', - status: - s.addr === gameAccount.transactorAddr - ? { kind: 'ready' } - : { - kind: 'pending', - accessVersion: s.accessVersion, - }, - }) - ) - gameAccount.players.forEach(p => - nodes.push({ - addr: p.addr, - id: p.accessVersion, - mode: 'player', - status: - p.addr === gameAccount.transactorAddr - ? { kind: 'ready' } - : { - kind: 'pending', - accessVersion: p.accessVersion, - }, - }) - ) - - const players = gameAccount.players - .filter(p => p.accessVersion <= checkpointAccessVersion) - .map( - p => - new ContextPlayer({ - id: p.accessVersion, - position: p.position, - }) - ) - - const spec = new GameSpec({ - gameAddr: gameAccount.addr, - gameId: 0, - bundleAddr: gameAccount.bundleAddr, - maxPlayers: gameAccount.maxPlayers, - }) - - const versions = new Versions({ - accessVersion: gameAccount.accessVersion, - settleVersion: gameAccount.settleVersion, - }) + versionedData: VersionedData + subGames: GameSpec[] + entryType: IEntryType - let subGames = [] - - for (const versionedData of checkpoint.data.values()) { - if (versionedData.id === 0) { - // Skip the master game - continue - } - subGames.push( - new SubGame({ - gameId: versionedData.id, - bundleAddr: versionedData.spec.bundleAddr, - initAccount: new InitAccount({ - maxPlayers: versionedData.spec.maxPlayers, - data: Uint8Array.of(), - }), - }) - ) + constructor(sharedData: ISharedData, versionedData: VersionedData) { + let subGames: GameSpec[] = [] + for (const vd of versionedData.subData.values()) { + subGames.push(vd.gameSpec) } - const balances = gameAccount.balances.map(x => new PlayerBalance(x)) - - this.spec = spec - this.versions = versions - this.status = 'idle' + this.gameSpec = versionedData.gameSpec + this.versions = versionedData.versions + this.status = 'Idle' this.dispatch = undefined - this.nodes = nodes + this.nodes = sharedData.nodes this.timestamp = 0n this.randomStates = [] this.decisionStates = [] - this.handlerState = Uint8Array.of() - this.checkpoint = checkpoint + this.handlerState = versionedData.handlerState + this.versionedData = versionedData this.subGames = subGames - this.initData = gameAccount.data - this.players = players - this.entryType = gameAccount.entryType - this.stateSha = '' - this.balances = balances - } - - subContext(subGame: SubGame): GameContext { - const c = structuredClone(this) - Object.setPrototypeOf(c, GameContext.prototype) - // Use init_account or checkpoint - let spec = new GameSpec({ - gameAddr: this.spec.gameAddr, - gameId: subGame.gameId, - bundleAddr: subGame.bundleAddr, - maxPlayers: subGame.initAccount.maxPlayers, - }) - c.checkpoint = this.checkpoint - c.initData = Uint8Array.of() - c.versions = Versions.default() - c.handlerState = Uint8Array.of() - c.nodes = this.nodes - c.spec = spec - c.dispatch = undefined - c.timestamp = 0n - c.randomStates = [] - c.decisionStates = [] - c.subGames = [] - c.entryType = { kind: 'disabled' } - c.players = [] - return c - } - - checkpointVersion(): bigint | undefined { - return this.checkpoint.getVersionedData(this.spec.gameId)?.versions.settleVersion - } - - initAccount(): InitAccount { - return new InitAccount({ - maxPlayers: this.spec.maxPlayers, - data: this.initData, - }) + this.entryType = versionedData.gameSpec.entryType.generalize() + this.balances = sharedData.balances } - // get checkpointStateSha(): string { - // return this.checkpoint.getSha(this.gameId) || ''; - // } - idToAddrUnchecked(id: bigint): string | undefined { return this.nodes.find(x => x.id === id)?.addr } @@ -346,7 +206,7 @@ export class GameContext { return this.randomStates.every(st => st.status.kind === 'ready') } - setNodeStatus(addr: string, status: NodeStatus) { + setNodeStatus(addr: string, status: INodeStatus) { let n = this.nodes.find(n => n.addr === addr) if (n === undefined) { throw new Error('Invalid node address') @@ -360,17 +220,21 @@ export class GameContext { addr: nodeAddr, id: accessVersion, mode, - status: { kind: 'pending', accessVersion }, + status: { kind: 'Pending', accessVersion }, }) } + removeNode(nodeAddr: string) { + this.nodes = this.nodes.filter(n => n.addr !== nodeAddr) + } + setAccessVersion(accessVersion: bigint) { this.versions.accessVersion = accessVersion } initRandomState(spec: RandomSpec): number { const randomId = this.randomStates.length + 1 - const owners = this.nodes.filter(n => n.status.kind === 'ready' && n.mode !== 'player').map(n => n.addr) + const owners = this.nodes.filter(n => n.status.kind === 'Ready' && n.mode !== 'Player').map(n => n.addr) const randomState = new RandomState(randomId, spec, owners) this.randomStates.push(randomState) return randomId @@ -483,7 +347,7 @@ export class GameContext { await this.setHandlerState(effect.handlerState) if (effect.isCheckpoint) { this.bumpSettleVersion() - this.checkpoint.setData(this.spec.gameId, effect.handlerState) + this.versionedData.handlerState = effect.handlerState // Reset random states this.randomStates = [] @@ -494,12 +358,18 @@ export class GameContext { this.balances = effect.balances } else if (effect.isInit) { this.bumpSettleVersion() - this.checkpoint.initData(this.spec.gameId, effect.handlerState, this.spec) + this.versionedData = VersionedData.init(this.gameSpec, this.versions, effect.handlerState) } } - for (const subGame of effect.launchSubGames) { - this.addSubGame(subGame) + for (const launchSubGame of effect.launchSubGames) { + this.subGames.push(new GameSpec({ + gameAddr: this.gameSpec.gameAddr, + gameId: launchSubGame.gameId, + bundleAddr: launchSubGame.bundleAddr, + maxPlayers: launchSubGame.initAccount.maxPlayers, + entryType: new EntryTypeDisabled({}), + })) } return { @@ -514,10 +384,10 @@ export class GameContext { setNodeReady(accessVersion: bigint) { for (const n of this.nodes) { - if (n.status.kind === 'pending') { + if (n.status.kind === 'Pending') { if (n.status.accessVersion <= accessVersion) { console.debug(`Set node ${n.addr} status to ready`) - n.status = { kind: 'ready' } + n.status = { kind: 'Ready' } } } } @@ -527,11 +397,11 @@ export class GameContext { this.timestamp = timestamp } - findSubGame(gameId: number): SubGame | undefined { + findSubGame(gameId: number): GameSpec | undefined { return this.subGames.find(g => g.gameId === Number(gameId)) } - addSubGame(subGame: SubGame) { + addSubGame(subGame: GameSpec) { const found = this.subGames.find(s => s.gameId === subGame.gameId) if (found === undefined) { this.subGames.push(subGame) @@ -539,22 +409,9 @@ export class GameContext { } async setHandlerState(state: Uint8Array) { - this.stateSha = await sha256String(state) this.handlerState = state } - addPlayer(player: ContextPlayer) { - this.players.push(player) - } - - removePlayer(playerId: bigint) { - this.players = this.players.filter(p => p.id !== playerId) - } - - getStateSha(): string { - return this.stateSha - } - get accessVersion(): bigint { return this.versions.accessVersion } diff --git a/packages/sdk-core/src/game-spec.ts b/packages/sdk-core/src/game-spec.ts new file mode 100644 index 0000000..b3608d1 --- /dev/null +++ b/packages/sdk-core/src/game-spec.ts @@ -0,0 +1,24 @@ +import { field, enums } from '@race-foundation/borsh' +import { Fields } from './types' +import { AEntryType } from './entry-type' + +export class GameSpec { + @field('string') + readonly gameAddr!: string + + @field('usize') + readonly gameId!: number + + @field('string') + readonly bundleAddr!: string + + @field('u16') + readonly maxPlayers!: number + + @field(enums(AEntryType)) + readonly entryType!: AEntryType + + constructor(fields: Fields) { + Object.assign(this, fields) + } +} diff --git a/packages/sdk-core/src/handler.ts b/packages/sdk-core/src/handler.ts index a60d126..c6fa853 100644 --- a/packages/sdk-core/src/handler.ts +++ b/packages/sdk-core/src/handler.ts @@ -1,5 +1,5 @@ import { deserialize, serialize } from '@race-foundation/borsh' -import { GameBundle } from './accounts' +import { IGameBundle } from './accounts' import { AnswerDecision, GameEvent, @@ -21,8 +21,6 @@ import { InitAccount } from './init-account' export interface IHandler { handleEvent(context: GameContext, event: GameEvent): Promise - - initState(context: GameContext, initAccount: InitAccount): Promise } export class Handler implements IHandler { @@ -44,7 +42,7 @@ export class Handler implements IHandler { } static async initialize( - gameBundle: GameBundle, + gameBundle: IGameBundle, encryptor: IEncryptor, client: Client, decryptionCache: DecryptionCache @@ -74,17 +72,10 @@ export class Handler implements IHandler { await this.generalPreHandleEvent(context, event, this.#encryptor) const eventEffects = await this.customHandleEvent(context, event) await this.generalPostHandleEvent(context, event, eventEffects) + console.info("Game context: ", structuredClone(context)) return eventEffects } - async initState(context: GameContext): Promise { - const initAccount = context.initAccount() - console.info('Initialize game state with:', initAccount) - context.setTimestamp(0n) // Use 0 timestamp for initState - await this.generalPreInitState(context, initAccount) - return await this.customInitState(context, initAccount) - } - async generalPreInitState(context: GameContext, _initAccount: InitAccount) { context.dispatch = undefined } @@ -123,10 +114,12 @@ export class Handler implements IHandler { const addr = context.idToAddr(sender) context.lock(addr, randomId, ciphertextsAndDigests) } else if (event instanceof Join) { - event.players.forEach(p => context.addPlayer(p)) } else if (event instanceof Leave) { + const { playerId } = event + const addr = context.idToAddr(playerId) + context.removeNode(addr) } else if (event instanceof GameStart) { - context.status = 'running' + context.status = 'Running' context.setNodeReady(context.accessVersion) } else if (event instanceof SecretsReady) { for (let randomId of event.randomIds) { diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index c236aef..aed89c6 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -1,4 +1,5 @@ export * from './transport' +export * from './encryptor' export * from './accounts' export * from './app-helper' export * from './app-client' @@ -8,8 +9,16 @@ export * from './connection' export * from './sub-client' export * from './types' export * from './checkpoint' +export * from './credentials' export * from './message' export * from './response' export * from './storage' +export * from './client-mode' +export * from './entry-type' +export * from './node' +export * from './player-balance' +export * from './versioned-data' +export * from './shared-data' +export * from './utils' console.info('Race Protocol 24.12') diff --git a/packages/sdk-core/src/init-account.ts b/packages/sdk-core/src/init-account.ts index f54a0a4..ea555b0 100644 --- a/packages/sdk-core/src/init-account.ts +++ b/packages/sdk-core/src/init-account.ts @@ -1,5 +1,5 @@ import { array, deserialize, enums, field, option, serialize, struct } from '@race-foundation/borsh' -import { EntryType, GameAccount } from './accounts' +import { IGameAccount } from './accounts' import { Fields } from './types' /** @@ -11,6 +11,13 @@ export class InitAccount { @field('u8-array') readonly data: Uint8Array + static fromGameAccount(gameAccount: IGameAccount) { + return new InitAccount({ + maxPlayers: gameAccount.maxPlayers, + data: gameAccount.data, + }) + } + constructor(fields: Fields) { this.maxPlayers = fields.maxPlayers this.data = fields.data diff --git a/packages/sdk-core/src/node.ts b/packages/sdk-core/src/node.ts index de365a1..1864786 100644 --- a/packages/sdk-core/src/node.ts +++ b/packages/sdk-core/src/node.ts @@ -1,7 +1,111 @@ -import { __set_WebSocket_impl } from './connection' -import { __set_subtle_impl } from './crypto' +import { field, struct, enums, variant } from '@race-foundation/borsh' +import { Indices, Fields } from './types' +import { CLIENT_MODES, ClientMode } from './client-mode' -export function setupNodeEnv() { - __set_subtle_impl(require('crypto').subtle) - __set_WebSocket_impl(require('ws')) +export type NodeStatusKind = 'Pending' | 'Confirming' | 'Ready' | 'Disconnected' + +export type INodeStatus = { kind: 'Pending', accessVersion: bigint } + | { kind: 'Confirming' } + | { kind: 'Ready' } + | { kind: 'Disconnected' } + +export abstract class ANodeStatus { + generalize(): INodeStatus { + if (this instanceof NodeStatusPending) { + return { + kind: 'Pending', accessVersion: this.accessVersion + } + } else if (this instanceof NodeStatusConfirming) { + return { kind: 'Confirming' } + } else if (this instanceof NodeStatusReady) { + return { kind: 'Ready' } + } else { + return { kind: 'Disconnected' } + } + } + + get kind(): NodeStatusKind { + return 'Disconnected' + } +} + +@variant(0) +export class NodeStatusPending extends ANodeStatus { + @field('u64') + accessVersion!: bigint + + get kind(): NodeStatusKind { + return 'Pending' + } + + constructor(fields: Fields) { + super(); + Object.assign(this, fields) + } +} + +@variant(1) +export class NodeStatusConfirming extends ANodeStatus { + get kind(): NodeStatusKind { + return 'Confirming' + } + + constructor() { + super(); + } +} + + +@variant(2) +export class NodeStatusReady extends ANodeStatus { + get kind(): NodeStatusKind { + return 'Ready' + } + + constructor() { + super(); + } +} + + +@variant(3) +export class NodeStatusDisconnected extends ANodeStatus { + get kind(): NodeStatusKind { + return 'Disconnected' + } + + constructor() { + super(); + } +} + +export interface INode { + addr: string + id: bigint + mode: ClientMode + status: INodeStatus +} + +export class Node { + @field('string') + addr!: string + @field('u64') + id!: bigint + @field('u8') + mode!: Indices + @field(enums(ANodeStatus)) + status!: ANodeStatus + + generalize(): INode { + return { + addr: this.addr, + id: this.id, + mode: CLIENT_MODES[this.mode], + status: this.status.generalize(), + } + } + + constructor(fields: Fields) { + Object.assign(this, fields) + } } diff --git a/packages/sdk-core/src/node_env.ts b/packages/sdk-core/src/node_env.ts new file mode 100644 index 0000000..de365a1 --- /dev/null +++ b/packages/sdk-core/src/node_env.ts @@ -0,0 +1,7 @@ +import { __set_WebSocket_impl } from './connection' +import { __set_subtle_impl } from './crypto' + +export function setupNodeEnv() { + __set_subtle_impl(require('crypto').subtle) + __set_WebSocket_impl(require('ws')) +} diff --git a/packages/sdk-core/src/player-balance.ts b/packages/sdk-core/src/player-balance.ts new file mode 100644 index 0000000..2f854fe --- /dev/null +++ b/packages/sdk-core/src/player-balance.ts @@ -0,0 +1,19 @@ +import { field } from '@race-foundation/borsh' +import { Fields } from './types' + +export interface IPlayerBalance { + readonly playerId: bigint + readonly balance: bigint +} + +export class PlayerBalance implements IPlayerBalance { + @field('u64') + playerId!: bigint + + @field('u64') + balance!: bigint + + constructor(fields: Fields) { + Object.assign(this, fields) + } +} diff --git a/packages/sdk-core/src/profile-loader.ts b/packages/sdk-core/src/profile-loader.ts index a005f40..d596b1e 100644 --- a/packages/sdk-core/src/profile-loader.ts +++ b/packages/sdk-core/src/profile-loader.ts @@ -1,4 +1,4 @@ -import { Nft } from './accounts' +import { INft } from './accounts' import { IStorage } from './storage' import { ITransport } from './transport' import { PlayerProfileWithPfp, ProfileCallbackFunction } from './types' @@ -32,7 +32,7 @@ export class ProfileLoader implements IProfileLoader { } } - async __getNft(addr: string): Promise { + async __getNft(addr: string): Promise { if (!this.__storage) { return await this.__transport.getNft(addr) } else { @@ -50,38 +50,52 @@ export class ProfileLoader implements IProfileLoader { async load(playerAddrs: string[]) { // 1, try to query the profiles those are already loaded - let addrsToLoad: string[] = [] + let addrsToLoad: string[] = [] // For those not cached for (const addr of playerAddrs) { const profile = this.__profiles.get(addr) - if (profile === undefined) { + if (!profile) { addrsToLoad.push(addr) } else { this.notify(profile) } } - // 2, try to serve the profile from storage - if (this.__storage) { - for (const addr of addrsToLoad) { - const profile = await this.__storage.getProfile(addr) - if (profile !== undefined) { - this.notify(profile) + if (addrsToLoad.length > 0) { + // 2, try to serve the profile from storage + let addrsToLoad2: string[] = [] // For those never loaded + if (this.__storage) { + for (const addr of addrsToLoad) { + const profile = await this.__storage.getProfile(addr) + // Make sure that credentials are available + if (profile && profile.credentials) { + this.__profiles.set(addr, profile) + this.notify(profile) + } else { + addrsToLoad2.push(addr) + } } + } else { + addrsToLoad2 = addrsToLoad // When storage is not available. } - } - // 3, load rest profiles - const profiles = await this.__transport.listPlayerProfiles(addrsToLoad) - for (const profile of profiles) { - if (profile) { - let nft = undefined - if (profile.pfp) { - nft = await this.__getNft(profile.pfp) - } - const profileWithPfp = { pfp: nft, nick: profile.nick, addr: profile.addr } - this.notify(profileWithPfp) - if (this.__storage) { - this.__storage.cacheProfile(profileWithPfp) + // 3, load rest profiles + if (addrsToLoad2.length > 0) { + const profiles = await this.__transport.listPlayerProfiles(addrsToLoad2) + for (const profile of profiles) { + if (profile) { + let nft = undefined + if (profile.pfp) { + nft = await this.__getNft(profile.pfp) + } + const profileWithPfp = { pfp: nft, nick: profile.nick, addr: profile.addr, credentials: profile.credentials } + this.__profiles.set(profile.addr, profileWithPfp) + this.notify(profileWithPfp) + + // Cache the profile if we are not on Facade. + if (this.__storage && this.__transport.chain !== 'facade') { + this.__storage.cacheProfile(profileWithPfp) + } + } } } } diff --git a/packages/sdk-core/src/random-state.ts b/packages/sdk-core/src/random-state.ts index 591ddee..b921b92 100644 --- a/packages/sdk-core/src/random-state.ts +++ b/packages/sdk-core/src/random-state.ts @@ -136,6 +136,7 @@ export class RandomState { revealed: Map constructor(id: number, spec: RandomSpec, owners: string[]) { + console.info('Initialize random state with owners: ', owners) if (owners.length === 0) { throw new Error('No enough servers') } @@ -392,4 +393,5 @@ export class RandomState { } this.status = { kind: 'shared' } } + } diff --git a/packages/sdk-core/src/shared-data.ts b/packages/sdk-core/src/shared-data.ts new file mode 100644 index 0000000..fc99146 --- /dev/null +++ b/packages/sdk-core/src/shared-data.ts @@ -0,0 +1,29 @@ +import { Node, INode } from './node' +import { ClientMode } from './client-mode' +import { array, field, struct } from '@race-foundation/borsh' +import { PlayerBalance } from './player-balance' +import { Fields } from './types' + +export interface ISharedData { + balances: PlayerBalance[] + nodes: INode[] +} + +export class SharedData { + @field(array(struct(PlayerBalance))) + balances!: PlayerBalance[] + + @field(array(struct(Node))) + nodes!: Node[] + + generalize(): ISharedData { + return { + balances: this.balances, + nodes: this.nodes.map(n => n.generalize()), + } + } + + constructor(fields: Fields) { + Object.assign(this, fields) + } +} diff --git a/packages/sdk-core/src/storage.ts b/packages/sdk-core/src/storage.ts index 5379a2e..62b5e9d 100644 --- a/packages/sdk-core/src/storage.ts +++ b/packages/sdk-core/src/storage.ts @@ -1,26 +1,37 @@ -import { Nft, Token, GameBundle } from './accounts' +import { INft, IToken, IGameBundle } from './accounts' import { EncryptorExportedKeys } from './encryptor' import { PlayerProfileWithPfp } from './types' const DB_KEY = 'race-protocol' -const DB_VER = 2 +const DB_VER = 5 + +const STORE_TOKENS = 'tokens' +const STORE_BUNDLES = 'bundles' +const STORE_NFTS = 'nfts' +const STORE_PROFILES= 'profiles' +const STORE_SECRETS = 'secrets' + +type SecretObject = { + addr: string + secret: Uint8Array +} export interface IStorage { - cacheTokens(tokens: Token[]): void + cacheTokens(tokens: IToken[]): void - getTokens(tokenAddrs: string[]): Promise> + getTokens(tokenAddrs: string[]): Promise> - cacheBundle(gameBundle: GameBundle): void + cacheBundle(gameBundle: IGameBundle): void - getBundle(addr: string): Promise + getBundle(addr: string): Promise - cacheNft(token: Nft): void + cacheNft(token: INft): void - getNft(nftAddr: string): Promise + getNft(nftAddr: string): Promise - cacheEncryptorKeys(keys: EncryptorExportedKeys): void + cacheSecret(playerAddr: string, originSecret: Uint8Array): void - getEncryptorKeys(playerAddr: string): Promise + getSecret(playerAddr: string): Promise cacheProfile(profile: PlayerProfileWithPfp): void @@ -33,31 +44,29 @@ export class Storage implements IStorage { request.onupgradeneeded = (_e: IDBVersionChangeEvent) => { const db = request.result - console.debug('Storage: creating object stores in IndexedDB') - - if (!db.objectStoreNames.contains('tokens')) { + if (!db.objectStoreNames.contains(STORE_TOKENS)) { console.debug(`Storage: creating object store "tokens" in IndexedDB`) - db.createObjectStore('tokens', { keyPath: 'addr' }) + db.createObjectStore(STORE_TOKENS, { keyPath: 'addr' }) } - if (!db.objectStoreNames.contains('nfts')) { + if (!db.objectStoreNames.contains(STORE_NFTS)) { console.debug(`Storage: creating object store "nfts" in IndexedDB`) - db.createObjectStore('nfts', { keyPath: 'addr' }) + db.createObjectStore(STORE_NFTS, { keyPath: 'addr' }) } - if (!db.objectStoreNames.contains('bundles')) { + if (!db.objectStoreNames.contains(STORE_BUNDLES)) { console.debug(`Storage: creating object store "bundles" in IndexedDB`) - db.createObjectStore('bundles', { keyPath: 'addr' }) + db.createObjectStore(STORE_BUNDLES, { keyPath: 'addr' }) } - if (!db.objectStoreNames.contains('profiles')) { + if (!db.objectStoreNames.contains(STORE_PROFILES)) { console.debug(`Storage: creating object store "profiles" in IndexedDB`) - db.createObjectStore('profiles', { keyPath: 'addr' }) + db.createObjectStore(STORE_PROFILES, { keyPath: 'addr' }) } - if (!db.objectStoreNames.contains('encryptor-keys')) { - console.debug(`Storage: creating object store "encryptor-keys" in IndexedDB`) - db.createObjectStore('encryptor-keys', { keyPath: 'playerAddr' }) + if (!db.objectStoreNames.contains(STORE_SECRETS)) { + console.debug(`Storage: creating object store "secrets" in IndexedDB`) + db.createObjectStore(STORE_SECRETS, { keyPath: 'addr' }) } } } @@ -67,8 +76,8 @@ export class Storage implements IStorage { request.onsuccess = _e => { let db = request.result - let tx = db.transaction('profiles', 'readwrite') - let store = tx.objectStore('profiles') + let tx = db.transaction(STORE_PROFILES, 'readwrite') + let store = tx.objectStore(STORE_PROFILES) // Allow replace the old one store.put(profile) @@ -87,7 +96,7 @@ export class Storage implements IStorage { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let read = db.transaction('profiles', 'readonly').objectStore('profiles').get(profileAddr) + let read = db.transaction(STORE_PROFILES, 'readonly').objectStore(STORE_PROFILES).get(profileAddr) read.onsuccess = _e => { const profile = read.result as PlayerProfileWithPfp | undefined resolve(profile) @@ -99,13 +108,13 @@ export class Storage implements IStorage { }) } - cacheTokens(tokens: Token[]) { + cacheTokens(tokens: IToken[]) { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let tx = db.transaction('tokens', 'readwrite') - let store = tx.objectStore('tokens') + let tx = db.transaction(STORE_TOKENS, 'readwrite') + let store = tx.objectStore(STORE_TOKENS) tokens.forEach(token => store.add(token)) tx.oncomplete = () => {} @@ -118,17 +127,17 @@ export class Storage implements IStorage { } } - getTokens(tokenAddrs: string[]): Promise> { + getTokens(tokenAddrs: string[]): Promise> { return new Promise((resolve, _reject) => { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let results: Array = [] + let results: Array = [] let count = 0 for (const tokenAddr of tokenAddrs) { - let read = db.transaction('tokens', 'readonly').objectStore('tokens').get(tokenAddr) + let read = db.transaction(STORE_TOKENS, 'readonly').objectStore(STORE_TOKENS).get(tokenAddr) read.onsuccess = _e => { - const token = read.result as Token | undefined + const token = read.result as IToken | undefined results.push(token) count++ if (count === tokenAddrs.length) { @@ -144,13 +153,13 @@ export class Storage implements IStorage { }) } - cacheNft(nft: Nft) { + cacheNft(nft: INft) { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let tx = db.transaction('nfts', 'readwrite') - let store = tx.objectStore('nfts') + let tx = db.transaction(STORE_NFTS, 'readwrite') + let store = tx.objectStore(STORE_NFTS) store.add(nft) @@ -160,14 +169,14 @@ export class Storage implements IStorage { } } - getNft(nftAddr: string): Promise { + getNft(nftAddr: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let read = db.transaction('nfts', 'readonly').objectStore('nfts').get(nftAddr) + let read = db.transaction(STORE_NFTS, 'readonly').objectStore(STORE_NFTS).get(nftAddr) read.onsuccess = _e => { - const nft = read.result as Nft | undefined + const nft = read.result as INft | undefined resolve(nft) } read.onerror = _e => { @@ -177,13 +186,14 @@ export class Storage implements IStorage { }) } - cacheBundle(bundle: GameBundle) { + + cacheBundle(bundle: IGameBundle) { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let tx = db.transaction('bundles', 'readwrite') - let store = tx.objectStore('bundles') + let tx = db.transaction(STORE_BUNDLES, 'readwrite') + let store = tx.objectStore(STORE_BUNDLES) store.add(bundle) @@ -193,14 +203,14 @@ export class Storage implements IStorage { } } - getBundle(bundleAddr: string): Promise { + getBundle(bundleAddr: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let read = db.transaction('bundles', 'readonly').objectStore('bundles').get(bundleAddr) + let read = db.transaction(STORE_BUNDLES, 'readonly').objectStore(STORE_BUNDLES).get(bundleAddr) read.onsuccess = _e => { - const bundle = read.result as GameBundle | undefined + const bundle = read.result as IGameBundle | undefined resolve(bundle) } read.onerror = _e => { @@ -210,30 +220,31 @@ export class Storage implements IStorage { }) } - cacheEncryptorKeys(keys: EncryptorExportedKeys) { + cacheSecret(addr: string, secret: Uint8Array) { + const obj = { addr, secret } const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let tx = db.transaction('encryptor-keys', 'readwrite') - let store = tx.objectStore('encryptor-keys') + let tx = db.transaction(STORE_SECRETS, 'readwrite') + let store = tx.objectStore(STORE_SECRETS) - store.add(keys) + store.add(obj) tx.oncomplete = () => { - console.log('Cached encryptor keys') + console.log('Cached credentials secret') } } } - getEncryptorKeys(playerAddr: string): Promise { + getSecret(addr: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_KEY, DB_VER) request.onsuccess = _e => { let db = request.result - let read = db.transaction('encryptor-keys', 'readonly').objectStore('encryptor-keys').get(playerAddr) + let read = db.transaction(STORE_SECRETS, 'readonly').objectStore(STORE_SECRETS).get(addr) read.onsuccess = _e => { - const keys = read.result as EncryptorExportedKeys | undefined - resolve(keys) + const obj = read.result as SecretObject | undefined + resolve(obj?.secret) } read.onerror = _e => { reject(read.error) diff --git a/packages/sdk-core/src/sub-client.ts b/packages/sdk-core/src/sub-client.ts index 0786e20..853470e 100644 --- a/packages/sdk-core/src/sub-client.ts +++ b/packages/sdk-core/src/sub-client.ts @@ -1,6 +1,7 @@ import { BaseClient } from './base-client' import { Client } from './client' import { IConnection, ConnectParams } from './connection' +import { IStorage } from './storage' import { DecryptionCache } from './decryption-cache' import { IEncryptor } from './encryptor' import { GameContext } from './game-context' @@ -26,6 +27,7 @@ export type SubClientCtorOpts = { client: Client transport: ITransport encryptor: IEncryptor + storage: IStorage profileLoader: IProfileLoader connection: IConnection gameContext: GameContext @@ -50,7 +52,7 @@ export class SubClient extends BaseClient { } __connect() { - const settleVersion = this.__gameContext.checkpointVersion() || 0n + const settleVersion = this.__gameContext.versionedData.versions.settleVersion || 0n this.__connection.connect(new ConnectParams({ settleVersion })) } @@ -61,7 +63,6 @@ export class SubClient extends BaseClient { console.group(`${this.__logPrefix}Attach to game`) try { this.__connect() - await this.__attachGameWithRetry() this.__startSubscribe() } catch (e) { console.error('Attaching game failed', e) diff --git a/packages/sdk-core/src/transport.ts b/packages/sdk-core/src/transport.ts index 3a89703..d3bd369 100644 --- a/packages/sdk-core/src/transport.ts +++ b/packages/sdk-core/src/transport.ts @@ -1,18 +1,18 @@ import { - GameAccount, - GameBundle, - ServerAccount, + IGameAccount, + IGameBundle, + IServerAccount, VoteType, - RegistrationAccount, - Nft, - Token, - RecipientAccount, - EntryType, + IRegistrationAccount, + INft, + IToken, + IRecipientAccount, TokenBalance, - PlayerProfile, + IPlayerProfile, } from './accounts' import { ResponseHandle } from './response' import { Result } from './types' +import { IEntryType } from './entry-type' export type SendTransactionResult = Result @@ -35,7 +35,7 @@ export type CreateGameAccountParams = { bundleAddr: string tokenAddr: string maxPlayers: number - entryType: EntryType + entryType: IEntryType registrationAddr: string recipientAddr: string data: Uint8Array @@ -60,8 +60,6 @@ export type JoinParams = { gameAddr: string amount: bigint position: number - verifyKey: string - createProfileIfNeeded?: boolean } export type JoinError = @@ -103,12 +101,13 @@ export type VoteParams = { } export type CreatePlayerProfileParams = { + secret: Uint8Array nick: string pfp?: string } export type CreatePlayerProfileResponse = { - profile: PlayerProfile + profile: IPlayerProfile signature: string } @@ -219,6 +218,8 @@ export type AddRecipientSlotError = export interface ITransport { walletAddr(wallet: W): string + get chain(): string + createGameAccount( wallet: W, params: CreateGameAccountParams, @@ -281,31 +282,35 @@ export interface ITransport { unregisterGame(wallet: W, params: UnregisterGameParams, resp: ResponseHandle): Promise - getGameAccount(addr: string): Promise + getGameAccount(addr: string): Promise + + listGameAccounts(addrs: string[]): Promise - listGameAccounts(addrs: string[]): Promise + getGameBundle(addr: string): Promise - getGameBundle(addr: string): Promise + getPlayerProfile(addr: string): Promise - getPlayerProfile(addr: string): Promise + listPlayerProfiles(addrs: string[]): Promise> - listPlayerProfiles(addrs: string[]): Promise> + listServerAccounts(addrs: string[]): Promise> - getServerAccount(addr: string): Promise + getServerAccount(addr: string): Promise - getRegistration(addr: string): Promise + getRegistration(addr: string): Promise - getRecipient(addr: string): Promise + getRecipient(addr: string): Promise getTokenDecimals(addr: string): Promise - getToken(addr: string): Promise + getToken(addr: string): Promise - getNft(addr: string): Promise + getNft(addr: string): Promise - listTokens(tokenAddrs: string[]): Promise + listTokens(tokenAddrs: string[]): Promise listTokenBalance(walletAddr: string, tokenAddrs: string[]): Promise - listNfts(walletAddr: string): Promise + listNfts(walletAddr: string): Promise + + getCredentialOriginSecret(wallet: W): Promise } diff --git a/packages/sdk-core/src/types.ts b/packages/sdk-core/src/types.ts index 2bc54c1..6a236c9 100644 --- a/packages/sdk-core/src/types.ts +++ b/packages/sdk-core/src/types.ts @@ -1,4 +1,5 @@ -import { EntryType, Nft, Token } from './accounts' +import { INft, IToken } from './accounts' +import { IEntryType } from './entry-type' import { Message } from './message' import { ConnectionState } from './connection' import { GameEvent } from './events' @@ -25,8 +26,8 @@ export type GameInfo = { gameAddr: string title: string maxPlayers: number - entryType: EntryType - token: Token + entryType: IEntryType + token: IToken tokenAddr: string bundleAddr: string data: Uint8Array @@ -34,9 +35,10 @@ export type GameInfo = { } export type PlayerProfileWithPfp = { - pfp: Nft | undefined + pfp: INft | undefined addr: string nick: string + credentials: Uint8Array } export type EventCallbackOptions = { diff --git a/packages/sdk-core/src/utils.ts b/packages/sdk-core/src/utils.ts index 8cc9540..bf7d85f 100644 --- a/packages/sdk-core/src/utils.ts +++ b/packages/sdk-core/src/utils.ts @@ -1,3 +1,22 @@ +import { subtle } from './crypto' + +export function hexToBuffer(hex: string): Uint8Array { + return new Uint8Array((hex.match(/[\da-f]{2}/gi) || []).map(function(h) { + return parseInt(h, 16); + })); +} + +export async function sha256(data: Uint8Array): Promise { + let hashBuffer = await subtle().digest('SHA-256', data) + return new Uint8Array(hashBuffer) +} + +export async function sha256String(data: Uint8Array): Promise { + return Array.from(await sha256(data)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join('') +} + export function arrayBufferToBase64(buffer: ArrayBuffer): string { let binary = '' let bytes = new Uint8Array(buffer) diff --git a/packages/sdk-core/src/versioned-data.ts b/packages/sdk-core/src/versioned-data.ts new file mode 100644 index 0000000..80a7041 --- /dev/null +++ b/packages/sdk-core/src/versioned-data.ts @@ -0,0 +1,75 @@ +import { array, deserialize, serialize, field, map, option, struct, enums } from '@race-foundation/borsh' +import { sha256 } from './utils' +import { Fields } from './types' +import { GameEvent } from './events' +import { EmitBridgeEvent, LaunchSubGame, PlayerBalance } from './effect' +import { SharedData } from './shared-data' +import { DispatchEvent } from './dispatch-event' +import { GameSpec } from './game-spec' +import { Versions } from './versions' +import { Node, INodeStatus } from './node' +import { ClientMode } from './client-mode' + +export class VersionedData { + @field(struct(GameSpec)) + gameSpec!: GameSpec + + @field(struct(Versions)) + versions!: Versions + + @field(map('usize', struct(VersionedData))) + subData!: Map + + @field('u8-array') + handlerState!: Uint8Array + + @field(option(struct(DispatchEvent))) + dispatch!: DispatchEvent | undefined + + @field(array(struct(EmitBridgeEvent))) + bridgeEvents!: EmitBridgeEvent[] + + constructor(fields: any) { + Object.assign(this, fields) + } + + static init(gameSpec: GameSpec, versions: Versions, handlerState: Uint8Array): VersionedData { + return new VersionedData({ + gameSpec, + versions, + handlerState, + subData: new Map(), + dispatch: undefined, + bridgeEvents: [], + }) + } + + async sha(): Promise { + const bs = await serialize(this); + return sha256(bs); + } + + getSubData(gameId: number): VersionedData | undefined { + return this.subData.get(gameId) + } + + initSubData(versionedData: VersionedData) { + const gameId = versionedData.gameSpec.gameId + + if (this.subData.has(gameId)) { + throw new Error('Checkpoint already exists') + } else { + this.subData.set(gameId, versionedData) + } + } + + updateSubData(versionedData: VersionedData) { + const gameId = versionedData.gameSpec.gameId + + if (this.subData.has(gameId)) { + this.subData.set(gameId, versionedData) + } else { + throw new Error('Missing checkpoint') + } + } +} diff --git a/packages/sdk-core/src/versions.ts b/packages/sdk-core/src/versions.ts new file mode 100644 index 0000000..228ad51 --- /dev/null +++ b/packages/sdk-core/src/versions.ts @@ -0,0 +1,19 @@ +import { field } from '@race-foundation/borsh' +import { Fields } from './types' + + +export class Versions { + @field('u64') + accessVersion!: bigint + + @field('u64') + settleVersion!: bigint + + static default(): Versions { + return new Versions({ accessVersion: 0n, settleVersion: 0n }) + } + + constructor(fields: Fields) { + Object.assign(this, fields) + } +} diff --git a/packages/sdk-core/tests/encryptor.spec.ts b/packages/sdk-core/tests/encryptor.spec.ts index 4c47958..e47b63a 100644 --- a/packages/sdk-core/tests/encryptor.spec.ts +++ b/packages/sdk-core/tests/encryptor.spec.ts @@ -1,5 +1,5 @@ import { assert } from 'chai'; -import { setupNodeEnv } from '../src/node' +import { setupNodeEnv } from '../src/node_env' import { encryptAes, decryptAes, diff --git a/packages/sdk-core/tests/events.spec.ts b/packages/sdk-core/tests/events.spec.ts index 7d54a48..8eb952d 100644 --- a/packages/sdk-core/tests/events.spec.ts +++ b/packages/sdk-core/tests/events.spec.ts @@ -2,7 +2,6 @@ import { makeCustomEvent, ICustomEvent, Custom, - Ready, SecretsReady, Shutdown, ShareSecrets, diff --git a/packages/sdk-facade/src/accounts.ts b/packages/sdk-facade/src/accounts.ts index c5c6215..e7bc410 100644 --- a/packages/sdk-facade/src/accounts.ts +++ b/packages/sdk-facade/src/accounts.ts @@ -12,11 +12,11 @@ import * as RaceCore from '@race-foundation/sdk-core' type RecipientSlotType = Indices export abstract class RecipientSlotOwner { - generalize(): RaceCore.RecipientSlotOwner { + generalize(): RaceCore.IRecipientSlotOwner { if (this instanceof RecipientSlotOwnerUnassigned) { - return { kind: 'unassigned', identifier: this.identifier } + return { kind: 'Unassigned', identifier: this.identifier } } else if (this instanceof RecipientSlotOwnerAssigned) { - return { kind: 'assigned', addr: this.addr } + return { kind: 'Assigned', addr: this.addr } } else { throw new Error('Invalid RecipientSlotOwner') } @@ -43,7 +43,7 @@ export class RecipientSlotOwnerAssigned extends RecipientSlotOwner { } } -export type EntryTypeKind = 'Invalid' | 'Cash' | 'Ticket' | 'Gating' | 'Disabled' +export type EntryTypeKind = 'invalid' | 'cash' | 'ticket' | 'gating' | 'disabled' export interface IEntryTypeKind { kind(): EntryTypeKind @@ -51,9 +51,9 @@ export interface IEntryTypeKind { export abstract class EntryType implements IEntryTypeKind { kind(): EntryTypeKind { - return 'Invalid' + return 'invalid' } - generalize(): RaceCore.EntryType { + generalize(): RaceCore.IEntryType { if (this instanceof EntryTypeCash) { return { kind: 'cash', @@ -90,7 +90,7 @@ export class EntryTypeCash extends EntryType implements IEntryTypeKind { Object.setPrototypeOf(this, EntryTypeCash.prototype) } kind(): EntryTypeKind { - return 'Cash' + return 'cash' } } @@ -104,7 +104,7 @@ export class EntryTypeTicket extends EntryType implements IEntryTypeKind { Object.setPrototypeOf(this, EntryTypeTicket.prototype) } kind(): EntryTypeKind { - return 'Ticket' + return 'ticket' } } @@ -118,7 +118,7 @@ export class EntryTypeGating extends EntryType implements IEntryTypeKind { Object.setPrototypeOf(this, EntryTypeGating.prototype) } kind(): EntryTypeKind { - return 'Gating' + return 'gating' } } @@ -129,7 +129,7 @@ export class EntryTypeDisabled extends EntryType implements IEntryTypeKind { Object.setPrototypeOf(this, EntryTypeDisabled.prototype) } kind(): EntryTypeKind { - return 'Disabled' + return 'disabled' } } @@ -155,6 +155,8 @@ export class ServerAccount { readonly addr!: string @field('string') readonly endpoint!: string + @field('u8-array') + readonly credentials!: Uint8Array constructor(fields: Fields) { Object.assign(this, fields) } @@ -167,8 +169,6 @@ export class PlayerJoin { readonly position!: number @field('u64') readonly accessVersion!: bigint - @field('string') - readonly verifyKey!: string constructor(fields: Fields) { Object.assign(this, fields) } @@ -181,8 +181,6 @@ export class ServerJoin { readonly endpoint!: string @field('u64') readonly accessVersion!: bigint - @field('string') - readonly verifyKey!: string constructor(fields: Fields) { Object.assign(this, fields) } @@ -216,7 +214,7 @@ export class Bonus { Object.assign(this, fields) } - generalize(): RaceCore.Bonus { + generalize(): RaceCore.IBonus { return { identifier: this.identifier, tokenAddr: this.tokenAddr, @@ -237,7 +235,7 @@ export class Vote { constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.Vote { + generalize(): RaceCore.IVote { return { voter: this.voter, votee: this.votee, @@ -392,10 +390,12 @@ export class PlayerProfile { readonly nick!: string @field(option('string')) readonly pfp: string | undefined + @field('u8-array') + readonly credentials!: Uint8Array constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.PlayerProfile { + generalize(): RaceCore.IPlayerProfile { return this } } @@ -410,7 +410,7 @@ export class RecipientSlotShare { constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.RecipientSlotShare { + generalize(): RaceCore.IRecipientSlotShare { return { owner: this.owner.generalize(), weights: this.weights, @@ -433,7 +433,7 @@ export class RecipientSlot { constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.RecipientSlot { + generalize(): RaceCore.IRecipientSlot { return { id: this.id, slotType: RECIPIENT_SLOT_TYPES[this.slotType], @@ -454,7 +454,7 @@ export class RecipientAccount { constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.RecipientAccount { + generalize(): RaceCore.IRecipientAccount { return { addr: this.addr, capAddr: this.capAddr, diff --git a/packages/sdk-facade/src/facade-transport.ts b/packages/sdk-facade/src/facade-transport.ts index 9edd298..7157d78 100644 --- a/packages/sdk-facade/src/facade-transport.ts +++ b/packages/sdk-facade/src/facade-transport.ts @@ -27,11 +27,13 @@ import { CreateRegistrationParams, DepositParams, AttachBonusParams, - Token, + IToken, TokenBalance, AddRecipientSlotError, AddRecipientSlotParams, AddRecipientSlotResponse, + hexToBuffer, + generateCredentials, } from '@race-foundation/sdk-core' import { deserialize } from '@race-foundation/borsh' import { FacadeWallet } from './facade-wallet' @@ -54,6 +56,7 @@ interface CreatePlayerProfileInstruction { playerAddr: string nick: string pfp?: string + credentials: number[] } interface CreateGameAccountInstruction { @@ -95,7 +98,7 @@ const nftMap: Record = { }, } -const tokenMap: Record = { +const tokenMap: Record = { FACADE_NATIVE: { name: 'Native Token', symbol: 'NATIVE', @@ -133,6 +136,10 @@ export class FacadeTransport implements ITransport { this.#url = url } + get chain(): string { + return 'facade' + } + walletAddr(wallet: FacadeWallet): string { return wallet.walletAddr } @@ -193,7 +200,7 @@ export class FacadeTransport implements ITransport { throw new Error('Method not implemented.') } - async listTokens(tokenAddrs: string[]): Promise { + async listTokens(tokenAddrs: string[]): Promise { return Object.values(tokenMap).filter(t => tokenAddrs.includes(t.addr)) } @@ -216,10 +223,28 @@ export class FacadeTransport implements ITransport { params: CreatePlayerProfileParams, response: ResponseHandle ): Promise { + console.log('params:', params) + const playerAddr = wallet.walletAddr - const ix: CreatePlayerProfileInstruction = { playerAddr, ...params } + + // Fetch the old one + const playerProfile = await this.getPlayerProfile(playerAddr) + + let credentials + if (!playerProfile) { + credentials = (await generateCredentials(params.secret)).serialize() + } else { + credentials = playerProfile.credentials + } + + const ix: CreatePlayerProfileInstruction = { + playerAddr, + credentials: [...credentials], + nick: params.nick, + pfp: params.pfp, + } const signature = await this.sendInstruction('create_profile', ix) - const profile = { addr: playerAddr, nick: params.nick, pfp: params.pfp } + const profile = { addr: playerAddr, nick: params.nick, pfp: params.pfp, credentials } console.info('Profile:', profile) response.succeed({ signature, profile }) } @@ -268,24 +293,19 @@ export class FacadeTransport implements ITransport { return response.failed('game-not-found') } const ix: JoinInstruction = { playerAddr, accessVersion: gameAccount.accessVersion, ...params } - if (params.createProfileIfNeeded) { - const createPlayerProfileIx: CreatePlayerProfileInstruction = { - playerAddr, - nick: wallet.walletAddr.substring(0, 6), - pfp: undefined, - } - await this.sendInstruction('create_profile', createPlayerProfileIx) - } + const signature = await this.sendInstruction('join', ix) + response.succeed({ signature }) } - async getGameAccount(addr: string): Promise { + async getGameAccount(addr: string): Promise { const data: Uint8Array | undefined = await this.fetchState('get_account_info', [addr]) if (data === undefined) return undefined - return deserialize(GameAccount, data).generalize() + const gameAccount = deserialize(GameAccount, data).generalize() + console.debug(`Got game account ${addr}:`, gameAccount) + return gameAccount } - - async listGameAccounts(addrs: string[]): Promise { + async listGameAccounts(addrs: string[]): Promise { let ret = [] for (const addr of addrs) { const gameAccount = await this.getGameAccount(addr) @@ -315,13 +335,16 @@ export class FacadeTransport implements ITransport { if (data === undefined) return undefined return deserialize(ServerAccount, data) } + async listServerAccounts(addrs: string[]): Promise> { + return await Promise.all(addrs.map(addr => this.getServerAccount(addr))) + } async getRegistration(addr: string): Promise { const data: Uint8Array | undefined = await this.fetchState('get_registration_info', [addr]) if (data === undefined) return undefined return deserialize(RegistrationAccount, data) } - async getRecipient(_addr: string): Promise { + async getRecipient(_addr: string): Promise { return undefined } @@ -329,7 +352,7 @@ export class FacadeTransport implements ITransport { return tokenMap[addr]?.decimals } - async getToken(addr: string): Promise { + async getToken(addr: string): Promise { return tokenMap[addr] } @@ -403,4 +426,8 @@ export class FacadeTransport implements ITransport { return undefined } } + + async getCredentialOriginSecret(wallet: FacadeWallet): Promise { + return hexToBuffer(this.walletAddr(wallet)) + } } diff --git a/packages/sdk-solana/src/accounts.ts b/packages/sdk-solana/src/accounts.ts index 6faaeac..0ec001c 100644 --- a/packages/sdk-solana/src/accounts.ts +++ b/packages/sdk-solana/src/accounts.ts @@ -1,125 +1,48 @@ import { Address } from '@solana/kit' import { publicKeyExt } from './utils' -import * as RaceCore from '@race-foundation/sdk-core' import { deserialize, serialize, field, option, array, struct, enums, variant } from '@race-foundation/borsh' - -export interface IPlayerState { - isInitialized: boolean - nick: string - pfpKey?: Address -} - -export interface IPlayerJoin { - key: Address - position: number - accessVersion: bigint - verifyKey: string -} - -export interface IPlayerDeposit { - key: Address - amount: bigint - accessVersion: bigint - settleVersion: bigint -} - -export interface IServerJoin { - key: Address - endpoint: string - accessVersion: bigint - verifyKey: string -} - -export interface IVote { - voterKey: Address - voteeKey: Address - voteType: VoteType -} - -export interface IBonus { - identifier: string - tokenAddr: Address - amount: bigint -} - -export interface IGameReg { - title: string - gameKey: Address - bundleKey: Address - regTime: bigint -} - -export interface IRegistryState { - isInitialized: boolean - isPrivate: boolean - size: number - ownerKey: Address - games: IGameReg[] -} - -export interface IGameState { - isInitialized: boolean - version: string - title: string - bundleKey: Address - stakeKey: Address - ownerKey: Address - tokenKey: Address - transactorKey: Address | undefined - accessVersion: bigint - settleVersion: bigint - maxPlayers: number - deposits: IPlayerDeposit[] - servers: IServerJoin[] - dataLen: number - data: Uint8Array - votes: IVote[] - unlockTime: bigint | undefined - entryType: AEntryType - recipientAddr: Address - checkpoint: Uint8Array - entryLock: EntryLock - bonuses: IBonus[] -} - -export interface IServerState { - isInitialized: boolean - key: Address - ownerKey: Address - endpoint: string -} - -export interface IRecipientState { - isInitialized: boolean - capAddr: Address | undefined - slots: IRecipientSlot[] -} +import * as RaceCore from '@race-foundation/sdk-core'; +import { + AEntryType, + CheckpointOnChain, + ENTRY_LOCKS, + Fields, + Indices, + RECIPIENT_SLOT_TYPES, + VOTE_TYPES, + IGameAccount, + IPlayerProfile, + IGameBundle, + IRecipientAccount, + IVote, + IBonus, + IServerAccount, + IServerJoin, + IPlayerJoin, + IPlayerDeposit, + IRegistrationAccount, + IRecipientSlot, + IGameRegistration, + IRecipientSlotShare, + IRecipientSlotOwner, +} from '@race-foundation/sdk-core' + + +export type EntryLock = RaceCore.Indices type RecipientSlotType = RaceCore.Indices -export interface IRecipientSlot { - readonly id: number - readonly slotType: RecipientSlotType - readonly tokenAddr: Address - readonly stakeAddr: Address - readonly shares: IRecipientSlotShare[] -} - -export interface IRecipientSlotShare { - readonly owner: RecipientSlotOwner - readonly weights: number - readonly claimAmount: bigint -} - -export class PlayerState implements IPlayerState { - @field('bool') - isInitialized!: boolean +export class PlayerState { + @field('u8') + version!: number @field('string') nick!: string @field(option(publicKeyExt)) pfpKey?: Address + @field('u8-array') + credentials!: Uint8Array - constructor(fields: IPlayerState) { + constructor(fields: Fields) { Object.assign(this, fields) } @@ -131,28 +54,29 @@ export class PlayerState implements IPlayerState { return deserialize(PlayerState, data) } - generalize(addr: Address): RaceCore.PlayerProfile { + generalize(addr: Address): IPlayerProfile { return { addr: addr, nick: this.nick, pfp: this.pfpKey, + credentials: this.credentials, } } } -type VoteType = RaceCore.Indices +type VoteType = Indices -export class Vote implements IVote { +export class Vote { @field(publicKeyExt) voterKey!: Address @field(publicKeyExt) voteeKey!: Address @field('u8') voteType!: VoteType - constructor(fields: IVote) { + constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.Vote { + generalize(): IVote { return { voter: this.voterKey, votee: this.voteeKey, @@ -161,53 +85,47 @@ export class Vote implements IVote { } } -export class ServerJoin implements IServerJoin { +export class ServerJoin { @field(publicKeyExt) key!: Address @field('string') endpoint!: string @field('u64') accessVersion!: bigint - @field('string') - verifyKey!: string constructor(fields: IServerJoin) { Object.assign(this, fields) } - generalize(): RaceCore.ServerJoin { + generalize(): IServerJoin { return { addr: this.key, endpoint: this.endpoint, accessVersion: this.accessVersion, - verifyKey: this.verifyKey, } } } -export class PlayerJoin implements IPlayerJoin { +export class PlayerJoin { @field(publicKeyExt) key!: Address @field('u16') position!: number @field('u64') accessVersion!: bigint - @field('string') - verifyKey!: string - constructor(fields: IPlayerJoin) { + constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.PlayerJoin { + generalize(): IPlayerJoin { return { addr: this.key, position: this.position, accessVersion: this.accessVersion, - verifyKey: this.verifyKey, } } } -export class PlayerDeposit implements IPlayerDeposit { +export class PlayerDeposit { @field(publicKeyExt) key!: Address @field('u64') @@ -219,11 +137,11 @@ export class PlayerDeposit implements IPlayerDeposit { @field('u8') status!: RaceCore.DepositStatus - constructor(fields: IPlayerJoin) { + constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.PlayerDeposit { + generalize(): IPlayerDeposit { return { addr: this.key, amount: this.amount, @@ -234,7 +152,7 @@ export class PlayerDeposit implements IPlayerDeposit { } } -export class Bonus implements IBonus { +export class Bonus { @field('string') identifier!: string @field(publicKeyExt) @@ -244,11 +162,11 @@ export class Bonus implements IBonus { @field('u64') amount!: bigint - constructor(fields: IBonus) { + constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.Bonus { + generalize(): IBonus { return { identifier: this.identifier, tokenAddr: this.tokenAddr, @@ -257,100 +175,18 @@ export class Bonus implements IBonus { } } -type EntryLock = RaceCore.Indices - -export abstract class AEntryType { - static from(entryType: RaceCore.EntryType) { - if (entryType.kind === 'cash') { - return new EntryTypeCash(entryType) - } else if (entryType.kind === 'ticket') { - return new EntryTypeTicket(entryType) - } else if (entryType.kind === 'gating') { - return new EntryTypeGating(entryType) - } else { - return new EntryTypeDisabled(entryType) - } - } - - generalize(): RaceCore.EntryType { - if (this instanceof EntryTypeCash) { - return { - kind: 'cash', - minDeposit: this.minDeposit, - maxDeposit: this.maxDeposit, - } - } else if (this instanceof EntryTypeTicket) { - return { - kind: 'ticket', - amount: this.amount, - } - } else if (this instanceof EntryTypeGating) { - return { - kind: 'gating', - collection: this.collection, - } - } else { - return { - kind: 'disabled', - } - } - } -} - -@variant(0) -export class EntryTypeCash extends AEntryType { - @field('u64') - minDeposit!: bigint - @field('u64') - maxDeposit!: bigint - constructor(fields: RaceCore.Fields) { - super() - Object.assign(this, fields) - Object.setPrototypeOf(this, EntryTypeCash.prototype) - } -} - -@variant(1) -export class EntryTypeTicket extends AEntryType { - @field('u64') - amount!: bigint - constructor(fields: RaceCore.Fields) { - super() - Object.assign(this, fields) - Object.setPrototypeOf(this, EntryTypeTicket.prototype) - } -} - -@variant(2) -export class EntryTypeGating extends AEntryType { - @field('string') - collection!: string - constructor(fields: RaceCore.Fields) { - super() - Object.assign(this, fields) - Object.setPrototypeOf(this, EntryTypeGating.prototype) - } -} - -@variant(3) -export class EntryTypeDisabled extends AEntryType { - constructor(_: RaceCore.Fields) { - super() - Object.setPrototypeOf(this, EntryTypeDisabled.prototype) - } -} export class PlayerBalance { @field('u64') playerId!: bigint @field('u64') balance!: bigint - constructor(fields: RaceCore.Fields) { + constructor(fields: Fields) { Object.assign(this, fields) } } -export class GameState implements IGameState { +export class GameState { @field('bool') isInitialized!: boolean @field('string') @@ -400,7 +236,7 @@ export class GameState implements IGameState { @field(array(struct(PlayerBalance))) balances!: PlayerBalance[] - constructor(fields: IGameState) { + constructor(fields: Fields) { Object.assign(this, fields) } @@ -412,7 +248,7 @@ export class GameState implements IGameState { return deserialize(GameState, data) } - generalize(addr: Address, players: PlayerJoin[]): RaceCore.GameAccount { + generalize(addr: Address, players: PlayerJoin[]): IGameAccount { let checkpointOnChain = undefined if (this.checkpoint.length !== 0) { checkpointOnChain = RaceCore.CheckpointOnChain.fromRaw(this.checkpoint) @@ -438,7 +274,7 @@ export class GameState implements IGameState { entryType: this.entryType.generalize(), recipientAddr: this.recipientAddr, checkpointOnChain, - entryLock: RaceCore.ENTRY_LOCKS[this.entryLock], + entryLock: ENTRY_LOCKS[this.entryLock], bonuses: this.bonuses.map(b => b.generalize()), balances: this.balances, } @@ -456,7 +292,7 @@ export class PlayersRegState { positionFlags!: number[] @field(array(struct(PlayerJoin))) players!: PlayerJoin[] - constructor(fields: IGameState) { + constructor(fields: Fields) { Object.assign(this, fields) } static deserialize(data: Uint8Array): PlayersRegState { @@ -464,7 +300,7 @@ export class PlayersRegState { } } -export class GameReg implements IGameReg { +export class GameReg { @field('string') title!: string @field(publicKeyExt) @@ -473,10 +309,10 @@ export class GameReg implements IGameReg { bundleKey!: Address @field('u64') regTime!: bigint - constructor(fields: IGameReg) { + constructor(fields: Fields) { Object.assign(this, fields) } - generalize(): RaceCore.GameRegistration { + generalize(): IGameRegistration { return { title: this.title, addr: this.gameKey, @@ -486,7 +322,7 @@ export class GameReg implements IGameReg { } } -export class RegistryState implements IRegistryState { +export class RegistryState { @field('bool') isInitialized!: boolean @field('bool') @@ -497,7 +333,7 @@ export class RegistryState implements IRegistryState { ownerKey!: Address @field(array(struct(GameReg))) games!: GameReg[] - constructor(fields: IRegistryState) { + constructor(fields: Fields) { Object.assign(this, fields) } @@ -509,7 +345,7 @@ export class RegistryState implements IRegistryState { return deserialize(RegistryState, data) } - generalize(addr: Address): RaceCore.RegistrationAccount { + generalize(addr: Address): IRegistrationAccount { return { addr, isPrivate: this.isPrivate, @@ -520,7 +356,7 @@ export class RegistryState implements IRegistryState { } } -export class ServerState implements IServerState { +export class ServerState { @field('bool') isInitialized!: boolean @field(publicKeyExt) @@ -529,8 +365,10 @@ export class ServerState implements IServerState { ownerKey!: Address @field('string') endpoint!: string + @field('u8-array') + credentials!: Uint8Array - constructor(fields: IServerState) { + constructor(fields: Fields) { Object.assign(this, fields) } @@ -542,15 +380,32 @@ export class ServerState implements IServerState { return deserialize(this, data) } - generalize(): RaceCore.ServerAccount { + generalize(): IServerAccount { return { addr: this.ownerKey, endpoint: this.endpoint, + credentials: this.credentials, } } } -export abstract class RecipientSlotOwner {} +export abstract class RecipientSlotOwner { + generalize(): IRecipientSlotOwner { + if (this instanceof RecipientSlotOwnerUnassigned) { + return { + kind: 'Unassigned', + identifier: this.identifier, + } + } else if (this instanceof RecipientSlotOwnerAssigned) { + return { + kind: 'Assigned', + addr: this.addr + } + } else { + throw new Error('Unsupported SlotOwner type') + } + } +} @variant(0) export class RecipientSlotOwnerUnassigned extends RecipientSlotOwner { @@ -572,7 +427,7 @@ export class RecipientSlotOwnerAssigned extends RecipientSlotOwner { } } -export class RecipientSlotShare implements IRecipientSlotShare { +export class RecipientSlotShare { @field(enums(RecipientSlotOwner)) owner!: RecipientSlotOwner @field('u16') @@ -583,16 +438,16 @@ export class RecipientSlotShare implements IRecipientSlotShare { Object.assign(this, fields) } - generalize(): RaceCore.RecipientSlotShare { - let owner: RaceCore.RecipientSlotOwner + generalize(): IRecipientSlotShare { + let owner: IRecipientSlotOwner if (this.owner instanceof RecipientSlotOwnerAssigned) { owner = { - kind: 'assigned', + kind: 'Assigned', addr: this.owner.addr, } } else if (this.owner instanceof RecipientSlotOwnerUnassigned) { owner = { - kind: 'unassigned', + kind: 'Unassigned', identifier: this.owner.identifier, } } else { @@ -606,7 +461,7 @@ export class RecipientSlotShare implements IRecipientSlotShare { } } -export class RecipientSlot implements IRecipientSlot { +export class RecipientSlot { @field('u8') id!: number @field('u8') @@ -621,7 +476,7 @@ export class RecipientSlot implements IRecipientSlot { Object.assign(this, fields) } - generalize(balance: bigint): RaceCore.RecipientSlot { + generalize(balance: bigint): IRecipientSlot { return { id: this.id, slotType: RaceCore.RECIPIENT_SLOT_TYPES[this.slotType], @@ -632,7 +487,7 @@ export class RecipientSlot implements IRecipientSlot { } } -export class RecipientState implements IRecipientState { +export class RecipientState { @field('bool') isInitialized!: boolean @field(option(publicKeyExt)) @@ -640,7 +495,7 @@ export class RecipientState implements IRecipientState { @field(array(struct(RecipientSlot))) slots!: RecipientSlot[] - constructor(fields: IRecipientState) { + constructor(fields: Fields) { Object.assign(this, fields) } @@ -652,7 +507,7 @@ export class RecipientState implements IRecipientState { return deserialize(this, data) } - generalize(addr: string, slots: RaceCore.RecipientSlot[]): RaceCore.RecipientAccount { + generalize(addr: string, slots: IRecipientSlot[]): IRecipientAccount { return { addr, capAddr: this.capAddr, diff --git a/packages/sdk-solana/src/constants.ts b/packages/sdk-solana/src/constants.ts index fd91aa2..604af70 100644 --- a/packages/sdk-solana/src/constants.ts +++ b/packages/sdk-solana/src/constants.ts @@ -16,7 +16,8 @@ export const GAME_ACCOUNT_LEN = 1400n export const PLAYERS_REG_HEAD_LEN = 152n export const PLAYERS_REG_INIT_LEN = PLAYERS_REG_HEAD_LEN + 4n export const PLAYER_INFO_LEN = 170n -export const PLAYER_PROFILE_SEED = 'race-player-1000' -export const SERVER_PROFILE_SEED = 'race-server-1000' +export const PLAYER_PROFILE_SEED = 'race-player-2000' +export const SERVER_PROFILE_SEED = 'race-server-2000' export const NAME_LEN = 16 // The maximum length of player's nick name export const TITLE_LEN = 32 // The maximum length of game title +export const PROFILE_VERSION = 2 diff --git a/packages/sdk-solana/src/instruction.ts b/packages/sdk-solana/src/instruction.ts index e8a89bf..d90cc1c 100644 --- a/packages/sdk-solana/src/instruction.ts +++ b/packages/sdk-solana/src/instruction.ts @@ -2,8 +2,8 @@ import { publicKeyExt } from './utils' import * as SPL from '@solana-program/token' import { PROGRAM_ID, METAPLEX_PROGRAM_ID, SYSVAR_RENT, NATIVE_MINT, SYSTEM_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS } from './constants' import { array, enums, field, serialize, struct } from '@race-foundation/borsh' -import { EntryType, Fields, RecipientClaimError, AttachBonusError, Result, CreateRegistrationError } from '@race-foundation/sdk-core' -import { AEntryType, GameState, RecipientSlotOwner, RecipientSlotOwnerAssigned, RecipientState, RecipientSlot } from './accounts' +import { AEntryType, Fields, RecipientClaimError, AttachBonusError, Result, CreateRegistrationError, IEntryType } from '@race-foundation/sdk-core' +import { GameState, RecipientSlotOwner, RecipientSlotOwnerAssigned, RecipientState, RecipientSlot } from './accounts' import { AccountRole, address, @@ -53,9 +53,13 @@ export class CreatePlayerProfileData extends Serialize { @field('string') nick: string - constructor(nick: string) { + @field('u8-array') + credentials: Uint8Array + + constructor(nick: string, credentials: Uint8Array) { super() this.nick = nick + this.credentials = credentials } } @@ -97,8 +101,6 @@ export class JoinGameData extends Serialize { settleVersion: bigint @field('u16') position: number - @field('string') - verifyKey: string constructor(params: IxParams) { super() @@ -106,7 +108,6 @@ export class JoinGameData extends Serialize { this.accessVersion = params.accessVersion this.settleVersion = params.settleVersion this.position = params.position - this.verifyKey = params.verifyKey } } @@ -235,9 +236,10 @@ export function createPlayerProfile( ownerKey: Address, profileKey: Address, nick: string, - pfpKey?: Address + pfpKey: Address | undefined, + credentials: Uint8Array, ): IInstruction { - const data = new CreatePlayerProfileData(nick).serialize() + const data = new CreatePlayerProfileData(nick, credentials).serialize() return { accounts: [ @@ -273,7 +275,7 @@ export type CreateGameOptions = { gameBundleKey: Address title: string maxPlayers: number - entryType: EntryType + entryType: IEntryType data: Uint8Array } @@ -375,7 +377,6 @@ export type JoinOptions = { accessVersion: bigint settleVersion: bigint position: number - verifyKey: string pda: Address } @@ -393,7 +394,6 @@ export function join(opts: JoinOptions): IInstruction { accessVersion, settleVersion, position, - verifyKey, pda, } = opts @@ -402,7 +402,6 @@ export function join(opts: JoinOptions): IInstruction { accessVersion, settleVersion, position, - verifyKey, }).serialize() return { diff --git a/packages/sdk-solana/src/solana-transport.ts b/packages/sdk-solana/src/solana-transport.ts index 236d3a7..f6c3e12 100644 --- a/packages/sdk-solana/src/solana-transport.ts +++ b/packages/sdk-solana/src/solana-transport.ts @@ -46,21 +46,23 @@ import { JoinParams, DepositParams, VoteParams, + EntryTypeCash, + EntryTypeTicket, CreatePlayerProfileParams, CreateRegistrationParams, CreateRegistrationResponse, CreateRegistrationError, RegisterGameParams, UnregisterGameParams, - GameAccount, - GameBundle, - PlayerProfile, - ServerAccount, - RegistrationAccount, - Token, - Nft, - RecipientAccount, - RecipientSlot, + IGameAccount, + IGameBundle, + IPlayerProfile, + IServerAccount, + IRegistrationAccount, + IToken, + INft, + IRecipientAccount, + IRecipientSlot, RecipientClaimParams, TokenBalance, ResponseHandle, @@ -87,6 +89,8 @@ import { AddRecipientSlotParams, AddRecipientSlotResponse, AddRecipientSlotError, + CREDENTIALS_MESSAGE, + generateCredentials, } from '@race-foundation/sdk-core' import * as instruction from './instruction' @@ -101,11 +105,10 @@ import { SERVER_PROFILE_SEED, PLAYERS_REG_INIT_LEN, PLAYER_INFO_LEN, + PROFILE_VERSION, } from './constants' import { - EntryTypeCash, - EntryTypeTicket, GameState, PlayerState, PlayersRegState, @@ -125,7 +128,9 @@ import { createDasRpc, MetaplexDASApi, Asset } from './metaplex' import type { SolanaWalletAdapterWallet } from '@solana/wallet-standard' import { IdentifierString } from '@wallet-standard/base' +const SECRET_MESSAGE = `These signatures will be used to generate encrypted keys within the game.` const SolanaSignAndSendTransaction = 'solana:signAndSendTransaction' +const SolanaSignMessage = 'solana:signMessage' const MAX_CONFIRM_TIMES = 32 const MAX_RETRIES_FOR_GET_PLAYERS_REG = 5 @@ -156,10 +161,6 @@ export class SolanaTransport implements ITransport { #rpcTransports: RpcTransport[] #nextTransport = 0 - walletAddr(wallet: SolanaWalletAdapterWallet): string { - return wallet.accounts[0].address - } - constructor(chain: IdentifierString, endpoints: string[]) constructor(chain: IdentifierString, endpoint: string) constructor(chain: IdentifierString, endpointOrEndponits: string | string[]) { @@ -172,6 +173,19 @@ export class SolanaTransport implements ITransport { this.#chain = chain } + get chain(): string { + return 'solana' + } + + walletAddr(wallet: SolanaWalletAdapterWallet): string { + return wallet.accounts[0].address + } + + async getCredentialOriginSecret(wallet: SolanaWalletAdapterWallet): Promise { + const originSecret = await this.signMessage(wallet, CREDENTIALS_MESSAGE) + return originSecret; + } + roundRobinTransport(): RpcTransport { const transport = this.#rpcTransports[this.#nextTransport] this.#nextTransport = (this.#nextTransport + 1) % this.#rpcTransports.length @@ -395,7 +409,7 @@ export class SolanaTransport implements ITransport { response: ResponseHandle ): Promise { const payer = this.useTransactionSendingSigner(wallet) - const { gameAddr, amount: amountRaw, position, verifyKey } = params + const { gameAddr, amount: amountRaw, position } = params const gameAccountKey = address(gameAddr) // Call RPC functions in Parallel @@ -440,16 +454,6 @@ export class SolanaTransport implements ITransport { let profileKey: Address if (profileKey0 !== undefined) { profileKey = profileKey0 - } else if (params.createProfileIfNeeded) { - const createProfile = await this._prepareCreatePlayerProfile(payer, { - nick: payer.address.substring(0, 6), - }) - if ('err' in createProfile) { - return response.failed(createProfile.err) - } - const { ixs: createProfileIxs, profileKey: pk } = createProfile.ok - ixs.push(...createProfileIxs) - profileKey = pk } else { return response.failed('profile-not-found') } @@ -505,7 +509,6 @@ export class SolanaTransport implements ITransport { accessVersion, settleVersion, position, - verifyKey, pda, }) @@ -821,22 +824,33 @@ export class SolanaTransport implements ITransport { } async _prepareCreatePlayerProfile( - payer: TransactionSigner, + wallet: SolanaWalletAdapterWallet, params: CreatePlayerProfileParams - ): Promise> { + ): Promise> { + + const payer = this.useTransactionSendingSigner(wallet) + let ixs = [] - const { nick, pfp } = params + const { nick, pfp, secret } = params if (nick.length > 16) { return { err: 'invalid-nick' } } console.info('Payer Public Key:', payer.address) + console.info('XXX secret:', secret) const profileKey = await this._getPlayerProfileAddress(payer.address) console.info('Player profile public key: ', profileKey) const profileAccountData = await this._getFinalizedBase64AccountData(profileKey) - if (!profileAccountData) { + let credentials; + if (profileAccountData && profileAccountData[0] == PROFILE_VERSION) { + // Not the first time + const state = PlayerState.deserialize(profileAccountData) + credentials = state.credentials; + } else { + credentials = (await generateCredentials(secret)).serialize() + const lamports = await this.rpc().getMinimumBalanceForRentExemption(PROFILE_ACCOUNT_LEN).send() const ix = getCreateAccountWithSeedInstruction({ baseAccount: payer, @@ -853,13 +867,14 @@ export class SolanaTransport implements ITransport { } const pfpKey = !pfp ? address('11111111111111111111111111111111') : address(pfp) - const createProfile = instruction.createPlayerProfile(payer.address, profileKey, nick, pfpKey) + const createProfile = instruction.createPlayerProfile(payer.address, profileKey, nick, pfpKey, credentials) console.info('Transaction Instruction[CreatePlayerProfile]:', createProfile) ixs.push(createProfile) return { ok: { ixs, profileKey, + credentials, }, } } @@ -871,11 +886,11 @@ export class SolanaTransport implements ITransport { ): Promise { const payer = this.useTransactionSendingSigner(wallet) let ixs: IInstruction[] = [] - const createPlayerProfile = await this._prepareCreatePlayerProfile(payer, params) + const createPlayerProfile = await this._prepareCreatePlayerProfile(wallet, params) if ('err' in createPlayerProfile) { return response.failed(createPlayerProfile.err) } - const { ixs: createProfileIxs, profileKey } = createPlayerProfile.ok + const { ixs: createProfileIxs, profileKey, credentials } = createPlayerProfile.ok ixs.push(...createProfileIxs) let tx = await makeTransaction(this.rpc(), payer, ixs) if ('err' in tx) { @@ -894,6 +909,7 @@ export class SolanaTransport implements ITransport { nick: params.nick, pfp: params.pfp, addr: profileKey, + credentials, }, }) } @@ -1258,7 +1274,7 @@ export class SolanaTransport implements ITransport { async unregisterGame(_payer: SolanaWalletAdapterWallet, _params: UnregisterGameParams): Promise { throw new Error('unimplemented') } - async getGameAccount(addr: string): Promise { + async getGameAccount(addr: string): Promise { const gameAccountKey = address(addr) const gameState = await this._getGameState(gameAccountKey) if (gameState !== undefined) { @@ -1277,7 +1293,7 @@ export class SolanaTransport implements ITransport { return undefined } } - async getGameBundle(addr: string): Promise { + async getGameBundle(addr: string): Promise { const mintKey = address(addr) const [metadataKey] = await getProgramDerivedAddress({ programAddress: METAPLEX_PROGRAM_ID, @@ -1289,7 +1305,6 @@ export class SolanaTransport implements ITransport { return undefined } const metadataState = Metadata.deserialize(metadataAccountData) - console.debug('Metadata of game bundle:', metadataState) let { uri, name } = metadataState.data // URI should contains the wasm property let resp = await fetch(trimString(uri)) @@ -1303,7 +1318,7 @@ export class SolanaTransport implements ITransport { data: new Uint8Array(0), } } - async getPlayerProfile(addr: string): Promise { + async getPlayerProfile(addr: string): Promise { const playerKey = address(addr) const profileKey = await this._getPlayerProfileAddress(playerKey) @@ -1317,9 +1332,9 @@ export class SolanaTransport implements ITransport { return undefined } } - async listPlayerProfiles(addrs: string[]): Promise> { + async listPlayerProfiles(addrs: string[]): Promise> { // We should truncate addresses by 100 - let results: Array = [] + let results: Array = [] for (let i = 0; i < addrs.length; i += 100) { const addrsChunk = addrs.slice(i, i + 100).map(address) const keys = await Promise.all(addrsChunk.map(addr => this._getPlayerProfileAddress(addr))) @@ -1328,7 +1343,7 @@ export class SolanaTransport implements ITransport { } return results } - async getServerAccount(addr: string): Promise { + async getServerAccount(addr: string): Promise { const serverKey = address(addr) const profileKey = await this._getServerProfileAddress(serverKey) @@ -1339,7 +1354,19 @@ export class SolanaTransport implements ITransport { return undefined } } - async getRegistration(addr: string): Promise { + async listServerAccounts(addrs: string[]): Promise> { + // We should truncate addresses by 100 + let results: Array = [] + for (let i = 0; i < addrs.length; i += 100) { + const addrsChunk = addrs.slice(i, i + 100).map(address) + const keys = await Promise.all(addrsChunk.map(addr => this._getPlayerProfileAddress(addr))) + const states = await this._getMultiServerStates(keys) + results.push(...states.map((state, j) => state?.generalize())) + } + return results + } + + async getRegistration(addr: string): Promise { const regKey = address(addr) const regState = await this._getRegState(regKey) if (regState !== undefined) { @@ -1349,7 +1376,7 @@ export class SolanaTransport implements ITransport { } } - async listGameAccounts(addrs: string[]): Promise { + async listGameAccounts(addrs: string[]): Promise { const keys = addrs.map(a => address(a)) const gameStates = await this._getMultiGameStates(keys) const playersRegAccountKeys = gameStates @@ -1357,7 +1384,7 @@ export class SolanaTransport implements ITransport { .map(s => s.playersRegAccount) const playersRegStates = await this._getMultiPlayersRegStates(playersRegAccountKeys) - let games: Array = [] + let games: Array = [] for (let i = 0; i < gameStates.length; i++) { const gs = gameStates[i] if (gs !== undefined) { @@ -1370,11 +1397,11 @@ export class SolanaTransport implements ITransport { return games } - async getRecipient(addr: string): Promise { + async getRecipient(addr: string): Promise { const recipientKey = address(addr) const recipientState = await this._getRecipientState(recipientKey) if (recipientState === undefined) return undefined - let slots: RecipientSlot[] = [] + let slots: IRecipientSlot[] = [] for (const slot of recipientState.slots) { let balance if (slot.tokenAddr == NATIVE_MINT) { @@ -1409,7 +1436,7 @@ export class SolanaTransport implements ITransport { return mint.data.decimals } - _parseAssetRespToToken(asset: Asset): Token | undefined { + _parseAssetRespToToken(asset: Asset): IToken | undefined { const { name, symbol } = asset.content.metadata const icon = asset.content.files?.[0]?.uri if (icon == undefined) { @@ -1428,7 +1455,7 @@ export class SolanaTransport implements ITransport { return token } - async _getMultipleAssetsAsTokens(addrs: Address[]): Promise> { + async _getMultipleAssetsAsTokens(addrs: Address[]): Promise> { const assetsResp = await this.dasRpc().getAssets({ids: addrs}).send() if ('result' in assetsResp) { @@ -1439,7 +1466,7 @@ export class SolanaTransport implements ITransport { } } - async _getAssetAsToken(addr: Address): Promise { + async _getAssetAsToken(addr: Address): Promise { const assetResp = await this.dasRpc().getAsset(addr).send() if ('result' in assetResp) { return this._parseAssetRespToToken(assetResp.result) @@ -1449,7 +1476,7 @@ export class SolanaTransport implements ITransport { } } - async getToken(addr: string): Promise { + async getToken(addr: string): Promise { const mintKey = address(addr) try { return await this._getAssetAsToken(mintKey) @@ -1459,7 +1486,7 @@ export class SolanaTransport implements ITransport { } } - async listTokens(rawMintAddrs: string[]): Promise { + async listTokens(rawMintAddrs: string[]): Promise { // In Solana, token specification is stored in Mint, user token wallet is stored in Token. // Here we are querying the Mints. @@ -1469,7 +1496,7 @@ export class SolanaTransport implements ITransport { let tokens = await this._getMultipleAssetsAsTokens(rawMintAddrs.map(a => address(a))) - return tokens.filter((t): t is Token => t !== undefined) + return tokens.filter((t): t is IToken => t !== undefined) } async __fetchAllToken(walletAddr: Address, mintAddrs: Address[]): Promise[]> { @@ -1531,7 +1558,7 @@ export class SolanaTransport implements ITransport { return result } - async getNft(addr: Address): Promise { + async getNft(addr: Address): Promise { const resp = await this.dasRpc().getAsset(addr).send() if ('result' in resp) { @@ -1540,7 +1567,7 @@ export class SolanaTransport implements ITransport { const image = item.content.links?.['image'] as string | undefined if (image !== undefined) { - const nft: Nft = { + const nft: INft = { addr: item.id, collection: collection, image, @@ -1556,14 +1583,14 @@ export class SolanaTransport implements ITransport { return undefined } - async listNfts(rawWalletAddr: string): Promise { + async listNfts(rawWalletAddr: string): Promise { const walletAddr = address(rawWalletAddr) const resp = await this.dasRpc() .getAssetsByOwner({ ownerAddress: walletAddr, }) .send() - let result: Nft[] = [] + let result: INft[] = [] if ('result' in resp) { const assetsResp = resp.result @@ -1572,7 +1599,7 @@ export class SolanaTransport implements ITransport { const image = item.content.links?.['image'] as string | undefined if (image !== undefined) { - const nft: Nft = { + const nft: INft = { addr: item.id, collection: collection, image, @@ -1662,6 +1689,28 @@ export class SolanaTransport implements ITransport { return ret } + async _getMultiServerStates(serverAccountKeys: Address[]): Promise> { + const accounts = await this.rpc().getMultipleAccounts(serverAccountKeys).send() + const ret: Array = [] + for (let i = 0; i < accounts.value.length; i++) { + const key = serverAccountKeys[i] + const accountInfo = accounts.value[i] + if (accountInfo !== null) { + try { + ret.push(ServerState.deserialize(base64ToUint8Array(accountInfo.data[0]))) + console.info('Found player profile %s', key) + } catch (_: any) { + ret.push(undefined) + console.warn('Skip invalid player profile %s', key) + } + } else { + ret.push(undefined) + console.warn('Player profile %s not exist', key) + } + } + return ret + } + // This function returns the account data in Uint8Array which is parsed from base64 string // format. async _getFinalizedBase64AccountData(addr: Address): Promise | undefined> { @@ -1767,6 +1816,19 @@ export class SolanaTransport implements ITransport { }, } } + + // Ref: https://github.com/anza-xyz/wallet-standard/blob/master/packages/core/features/src/signMessage.ts + async signMessage(wallet: SolanaWalletAdapterWallet, message: Uint8Array): Promise { + const account = wallet.accounts[0] + console.info('XXX Account:', account) + console.info('XXX Message:', message) + console.info('XXX wallet.features[SolanaSignMessage]', wallet.features[SolanaSignMessage]) + const resps = await wallet.features[SolanaSignMessage]!.signMessage({ + account, + message, + }) + return resps[0].signedMessage + } } async function sendTransaction( diff --git a/packages/sdk-solana/tests/accounts.spec.ts b/packages/sdk-solana/tests/accounts.spec.ts index 3886933..a0d964f 100644 --- a/packages/sdk-solana/tests/accounts.spec.ts +++ b/packages/sdk-solana/tests/accounts.spec.ts @@ -100,7 +100,6 @@ describe('Test account data serialization', () => { balance: BigInt(100), accessVersion: BigInt(1), position: 0, - verifyKey: 'key0', }), ], servers: [ @@ -108,7 +107,6 @@ describe('Test account data serialization', () => { key: PublicKey.unique(), endpoint: 'http://foo.bar', accessVersion: BigInt(2), - verifyKey: 'key1', }), ], dataLen: 10, diff --git a/packages/sdk-sui/src/sui-transport.ts b/packages/sdk-sui/src/sui-transport.ts index b47e255..8bf7078 100644 --- a/packages/sdk-sui/src/sui-transport.ts +++ b/packages/sdk-sui/src/sui-transport.ts @@ -8,18 +8,18 @@ import { CreateGameAccountParams, CreatePlayerProfileParams, DepositParams, - GameAccount, - GameBundle, - Nft, - Token, + IGameAccount, + IGameBundle, + INft, + IToken, ITransport, JoinParams, - PlayerProfile, - RecipientAccount, + IPlayerProfile, + IRecipientAccount, RecipientClaimParams, RegisterGameParams, - RegistrationAccount, - ServerAccount, + IRegistrationAccount, + IServerAccount, UnregisterGameParams, ResponseHandle, CreateGameResponse, @@ -102,6 +102,10 @@ export class SuiTransport implements ITransport { this.packageId = packageId } + get chain(): string { + return 'sui' + } + walletAddr(wallet: WalletAdapter): string { return wallet.accounts[0].address } @@ -255,7 +259,7 @@ export class SuiTransport implements ITransport { } } - async getPlayerProfile(addr: string): Promise { + async getPlayerProfile(addr: string): Promise { const resp = await this.suiClient.getOwnedObjects({ owner: addr, filter: { @@ -269,7 +273,7 @@ export class SuiTransport implements ITransport { return parseObjectData(parseFirstObjectResponse(resp), PlayerPorfileParser) } - async listPlayerProfiles(addrs: string[]): Promise> { + async listPlayerProfiles(addrs: string[]): Promise> { return await Promise.all(addrs.map(addr => this.getPlayerProfile(addr))) } @@ -286,15 +290,14 @@ export class SuiTransport implements ITransport { params: JoinParams, resp: ResponseHandle ): Promise { - const { gameAddr, amount, position, verifyKey, createProfileIfNeeded = false } = params + const { gameAddr, amount, position } = params const suiClient = this.suiClient const playerProfile = await this.getPlayerProfile(this.walletAddr(wallet)) - if (playerProfile === undefined && createProfileIfNeeded) { - let res = new ResponseHandle() - await this.createPlayerProfile(wallet, { nick: this.walletAddr(wallet).substring(0, 6) }, res) + if (playerProfile === undefined) { + throw new Error('Profile not exist') } // get game object for token info and object ref @@ -363,7 +366,6 @@ export class SuiTransport implements ITransport { transaction.sharedObjectRef(gameAccountObjRef), transaction.pure.u16(position), transaction.pure.u64(amount), - transaction.pure.string(verifyKey), payment, ], typeArguments: [game.tokenAddr], @@ -630,7 +632,7 @@ export class SuiTransport implements ITransport { unregisterGame(_wallet: WalletAdapter, _params: UnregisterGameParams, _resp: ResponseHandle): Promise { throw new Error('Method not implemented.') } - async getGameAccount(addr: string): Promise { + async getGameAccount(addr: string): Promise { const suiClient = this.suiClient const resp: SuiObjectResponse = await suiClient.getObject({ id: addr, @@ -643,7 +645,7 @@ export class SuiTransport implements ITransport { return parseObjectData(parseSingleObjectResponse(resp), GameAccountParser) } - async listGameAccounts(addrs: string[]): Promise { + async listGameAccounts(addrs: string[]): Promise { let ret = [] for (const addr of addrs) { const gameAccount = await this.getGameAccount(addr) @@ -654,7 +656,7 @@ export class SuiTransport implements ITransport { return ret } - async getGameBundle(addr: string): Promise { + async getGameBundle(addr: string): Promise { const suiClient = this.suiClient const resp: SuiObjectResponse = await suiClient.getObject({ id: addr, @@ -666,7 +668,7 @@ export class SuiTransport implements ITransport { return parseObjectData(parseSingleObjectResponse(resp), GameBundleParser) } - async getServerAccount(addr: string): Promise { + async getServerAccount(addr: string): Promise { const resp = await this.suiClient.getOwnedObjects({ owner: addr, filter: { StructType: getServerStructType(this.packageId) }, @@ -675,7 +677,7 @@ export class SuiTransport implements ITransport { return parseObjectData(parseFirstObjectResponse(resp), ServerParser) } - async getRegistration(addr: string): Promise { + async getRegistration(addr: string): Promise { const suiClient = this.suiClient const resp: SuiObjectResponse = await suiClient.getObject({ id: addr, @@ -687,7 +689,7 @@ export class SuiTransport implements ITransport { return parseObjectData(parseSingleObjectResponse(resp), RegistrationAccountParser) } - async getRecipient(addr: string): Promise { + async getRecipient(addr: string): Promise { const suiClient = this.suiClient const resp: SuiObjectResponse = await suiClient.getObject({ id: addr, @@ -702,11 +704,11 @@ export class SuiTransport implements ITransport { async getTokenDecimals(addr: string): Promise { return this.getToken(addr).then(token => token?.decimals) } - async getToken(addr: string): Promise { + async getToken(addr: string): Promise { const suiClient = this.suiClient const tokenMetadata = await suiClient.getCoinMetadata({ coinType: addr }) if (!tokenMetadata) return undefined - const token: Token = { + const token: IToken = { addr: addr, icon: tokenMetadata.iconUrl || SUI_ICON_URL, name: tokenMetadata.name, @@ -716,7 +718,7 @@ export class SuiTransport implements ITransport { return token } - async getNft(addr: string): Promise { + async getNft(addr: string): Promise { const suiClient = this.suiClient const objectResponse: SuiObjectResponse = await suiClient.getObject({ id: addr, @@ -755,11 +757,11 @@ export class SuiTransport implements ITransport { return undefined } - async listTokens(tokenAddrs: string[]): Promise { + async listTokens(tokenAddrs: string[]): Promise { const promises = tokenAddrs.map(async addr => { return await this.getToken(addr) }) - let tokens = (await Promise.all(promises)).filter((t): t is Token => t !== undefined) + let tokens = (await Promise.all(promises)).filter((t): t is IToken => t !== undefined) return tokens } @@ -772,7 +774,7 @@ export class SuiTransport implements ITransport { .filter(t => tokenAddrs.includes(t.addr)) } - async listNfts(walletAddr: string): Promise { + async listNfts(walletAddr: string): Promise { const suiClient = this.suiClient const tokenMetadata = await suiClient.getOwnedObjects({ owner: walletAddr }) if (!tokenMetadata) return [] @@ -811,7 +813,7 @@ export class SuiTransport implements ITransport { } return undefined }) - .filter((obj: Nft | undefined): obj is Nft => obj !== undefined) + .filter((obj: INft | undefined): obj is INft => obj !== undefined) return nfts } recipientClaim( diff --git a/packages/sdk-sui/src/types/game-bundle.ts b/packages/sdk-sui/src/types/game-bundle.ts index 0b18465..a116d7f 100644 --- a/packages/sdk-sui/src/types/game-bundle.ts +++ b/packages/sdk-sui/src/types/game-bundle.ts @@ -1,6 +1,6 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' -import { GameBundle } from '@race-foundation/sdk-core' +import { IGameBundle } from '@race-foundation/sdk-core' const GameBundleSchema = bcs.struct('GameBundle', { addr: Address, // game nft object id @@ -10,9 +10,9 @@ const GameBundleSchema = bcs.struct('GameBundle', { cover: bcs.string(), // cover image url }) -export const GameBundleParser: Parser = { +export const GameBundleParser: Parser = { schema: GameBundleSchema, - transform: (input: typeof GameBundleSchema.$inferType): GameBundle => { + transform: (input: typeof GameBundleSchema.$inferType): IGameBundle => { return { addr: input.addr, uri: input.uri, diff --git a/packages/sdk-sui/src/types/game.ts b/packages/sdk-sui/src/types/game.ts index 5cf39f1..ab319a5 100644 --- a/packages/sdk-sui/src/types/game.ts +++ b/packages/sdk-sui/src/types/game.ts @@ -1,15 +1,15 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' import { - Bonus, - PlayerBalance, + IBonus, + IPlayerBalance, CheckpointOnChain, EntryLock, - EntryType, - GameAccount, - PlayerJoin, - PlayerDeposit, - ServerJoin, + IEntryType, + IGameAccount, + IPlayerJoin, + IPlayerDeposit, + IServerJoin, VoteType, ENTRY_LOCKS, VOTE_TYPES, @@ -23,9 +23,9 @@ const BonusSchema = bcs.struct('BonusSchema', { amount: bcs.u64(), }) -const BonusParser: Parser = { +const BonusParser: Parser = { schema: BonusSchema, - transform: (input: typeof BonusSchema.$inferType): Bonus => { + transform: (input: typeof BonusSchema.$inferType): IBonus => { return { identifier: input.identifier, tokenAddr: input.tokenAddr, @@ -39,9 +39,9 @@ const PlayerBalanceSchema = bcs.struct('PlayerBalance', { balance: bcs.u64(), }) -const PlayerBalanceParser: Parser = { +const PlayerBalanceParser: Parser = { schema: PlayerBalanceSchema, - transform: (input: typeof PlayerBalanceSchema.$inferType): PlayerBalance => { + transform: (input: typeof PlayerBalanceSchema.$inferType): IPlayerBalance => { return { playerId: BigInt(input.playerId), balance: BigInt(input.balance), @@ -70,9 +70,9 @@ const EntryTypeSchema = bcs.enum('EntryTypeSchema', { Disabled: null, }) -const EntryTypeParser: Parser = { +const EntryTypeParser: Parser = { schema: EntryTypeSchema, - transform: (input: typeof EntryTypeSchema.$inferType): EntryType => { + transform: (input: typeof EntryTypeSchema.$inferType): IEntryType => { if (input.$kind === 'Cash') { return { minDeposit: BigInt(input.Cash.min_deposit), @@ -90,7 +90,7 @@ const EntryTypeParser: Parser = { kind: 'gating', } } else { - return { kind: 'disabled' } + return { kind: 'Disabled' } } }, } @@ -113,7 +113,6 @@ const GameSchema = bcs.struct('Game', { addr: Address, position: bcs.u16(), accessVersion: bcs.u64(), - verifyKey: bcs.string(), }) ), deposits: bcs.vector( @@ -130,7 +129,6 @@ const GameSchema = bcs.struct('Game', { addr: Address, endpoint: bcs.string(), accessVersion: bcs.u64(), - verifyKey: bcs.string(), }) ), balance: bcs.u64(), @@ -152,9 +150,9 @@ const GameSchema = bcs.struct('Game', { }) // Transform function to convert from BCS to the TypeScript type -export const GameAccountParser: Parser = { +export const GameAccountParser: Parser = { schema: GameSchema, - transform: (input: typeof GameSchema.$inferType): GameAccount => { + transform: (input: typeof GameSchema.$inferType): IGameAccount => { console.info('input.checkpointOnChain =>', input.checkpointOnChain) console.info('input.transactorAddr =>', input.transactorAddr) @@ -170,7 +168,6 @@ export const GameAccountParser: Parser = { addr: player.addr, position: player.position, accessVersion: BigInt(player.accessVersion), - verifyKey: player.verifyKey, })), deposits: Array.from(input.deposits).map(deposit => ({ addr: deposit.addr, @@ -183,7 +180,6 @@ export const GameAccountParser: Parser = { addr: server.addr, endpoint: server.endpoint, accessVersion: BigInt(server.accessVersion), - verifyKey: server.verifyKey, })), transactorAddr: input.transactorAddr ?? undefined, votes: Array.from(input.votes).map(vote => ({ diff --git a/packages/sdk-sui/src/types/player-profile.ts b/packages/sdk-sui/src/types/player-profile.ts index 1265e1a..37fce9a 100644 --- a/packages/sdk-sui/src/types/player-profile.ts +++ b/packages/sdk-sui/src/types/player-profile.ts @@ -1,6 +1,6 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' -import { PlayerProfile } from '@race-foundation/sdk-core' +import { IPlayerProfile } from '@race-foundation/sdk-core' const PlayerProfileSchema = bcs.struct('PlayerProfile', { id: Address, @@ -9,9 +9,9 @@ const PlayerProfileSchema = bcs.struct('PlayerProfile', { pfp: bcs.option(Address), }) -export const PlayerPorfileParser: Parser = { +export const PlayerPorfileParser: Parser = { schema: PlayerProfileSchema, - transform: (input: typeof PlayerProfileSchema.$inferType): PlayerProfile => { + transform: (input: typeof PlayerProfileSchema.$inferType): IPlayerProfile => { return { addr: input.owner, nick: input.nick, diff --git a/packages/sdk-sui/src/types/recipient.ts b/packages/sdk-sui/src/types/recipient.ts index 5e584e1..a7e7817 100644 --- a/packages/sdk-sui/src/types/recipient.ts +++ b/packages/sdk-sui/src/types/recipient.ts @@ -1,10 +1,10 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' import { - RecipientAccount, - RecipientSlot, - RecipientSlotOwner, - RecipientSlotShare, + IRecipientAccount, + IRecipientSlot, + IRecipientSlotOwner, + IRecipientSlotShare, RecipientSlotType, RECIPIENT_SLOT_TYPES, } from '@race-foundation/sdk-core' @@ -45,9 +45,9 @@ const RecipientAccountSchema = bcs.struct('RecipientAccount', { }) // Define the parser for RecipientAccount -export const RecipientAccountParser: Parser = { +export const RecipientAccountParser: Parser = { schema: RecipientAccountSchema, - transform: (input: typeof RecipientAccountSchema.$inferType): RecipientAccount => { + transform: (input: typeof RecipientAccountSchema.$inferType): IRecipientAccount => { return { addr: input.addr, capAddr: input.capAddr ? input.capAddr : undefined, @@ -58,8 +58,8 @@ export const RecipientAccountParser: Parser ({ owner: 'unassigned' in share.owner && share.owner.unassigned - ? { identifier: share.owner.unassigned.identifier, kind: 'unassigned' } - : { addr: share.owner.assigned.addr, kind: 'assigned' }, + ? { identifier: share.owner.unassigned.identifier, kind: 'Unassigned' } + : { addr: share.owner.assigned.addr, kind: 'Assigned' }, weights: share.weights, claimAmount: BigInt(share.claimAmount), })), diff --git a/packages/sdk-sui/src/types/registration.ts b/packages/sdk-sui/src/types/registration.ts index b116570..c0ee4a5 100644 --- a/packages/sdk-sui/src/types/registration.ts +++ b/packages/sdk-sui/src/types/registration.ts @@ -1,6 +1,6 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' -import { RegistrationAccount } from '@race-foundation/sdk-core' +import { IRegistrationAccount } from '@race-foundation/sdk-core' // Define the GameRegistrationSchema to use in RegistrationAccountSchema const GameRegistrationSchema = bcs.struct('GameRegistration', { @@ -20,9 +20,9 @@ const RegistrationAccountSchema = bcs.struct('RegistrationAccount', { }) // Create the parser for RegistrationAccount -export const RegistrationAccountParser: Parser = { +export const RegistrationAccountParser: Parser = { schema: RegistrationAccountSchema, - transform: (input: typeof RegistrationAccountSchema.$inferType): RegistrationAccount => { + transform: (input: typeof RegistrationAccountSchema.$inferType): IRegistrationAccount => { return { addr: input.addr, isPrivate: input.isPrivate, diff --git a/packages/sdk-sui/src/types/server.ts b/packages/sdk-sui/src/types/server.ts index db33b2b..98ff3c4 100644 --- a/packages/sdk-sui/src/types/server.ts +++ b/packages/sdk-sui/src/types/server.ts @@ -1,6 +1,6 @@ import { bcs } from '@mysten/bcs' import { Address, Parser } from './parser' -import { ServerAccount } from '@race-foundation/sdk-core' +import { IServerAccount } from '@race-foundation/sdk-core' const ServerSchema = bcs.struct('Server', { id: Address, @@ -8,9 +8,9 @@ const ServerSchema = bcs.struct('Server', { endpoint: bcs.string(), }) -export const ServerParser: Parser = { +export const ServerParser: Parser = { schema: ServerSchema, - transform: (input: typeof ServerSchema.$inferType): ServerAccount => { + transform: (input: typeof ServerSchema.$inferType): IServerAccount => { return { addr: input.id, endpoint: input.endpoint,