UNPKG

@river-build/web3

Version:

Dapps for our Space and Registry contracts

1,089 lines (1,088 loc) 52.6 kB
import { EntitlementModuleType, isPermission, isUpdateChannelStatusParams, Permission, } from '../ContractTypes'; import { ethers } from 'ethers'; import { LOCALHOST_CHAIN_ID } from '../Web3Constants'; import { SpaceRegistrar } from './SpaceRegistrar'; import { createEntitlementStruct, createLegacyEntitlementStruct } from '../ConvertersRoles'; import { convertRuleDataV1ToV2 } from '../ConvertersEntitlements'; import { WalletLink, INVALID_ADDRESS } from './WalletLink'; import { RiverAirdropDapp, UNKNOWN_ERROR, } from './index'; import { PricingModules } from './PricingModules'; import { dlogger, isTestEnv } from '@river-build/dlog'; import { EVERYONE_ADDRESS, stringifyChannelMetadataJSON, NoEntitledWalletError } from '../Utils'; import { evaluateOperationsForEntitledWallet, ruleDataToOperations, } from '../entitlement'; import { PlatformRequirements } from './PlatformRequirements'; import { EntitlementCache } from '../EntitlementCache'; const logger = dlogger('csb:SpaceDapp:debug'); class EntitlementDataCacheResult { value; cacheHit; isPositive; constructor(value) { this.value = value; this.cacheHit = false; this.isPositive = true; } } class EntitledWalletCacheResult { value; cacheHit; isPositive; constructor(value) { this.value = value; this.cacheHit = false; this.isPositive = value !== undefined; } } class BooleanCacheResult { value; cacheHit; isPositive; constructor(value) { this.value = value; this.cacheHit = false; this.isPositive = value; } } class EntitlementRequest { spaceId; channelId; userId; permission; constructor(spaceId, channelId, userId, permission) { this.spaceId = spaceId; this.channelId = channelId; this.userId = userId; this.permission = permission; } toKey() { return `{spaceId:${this.spaceId},channelId:${this.channelId},userId:${this.userId},permission:${this.permission}}`; } } function newSpaceEntitlementEvaluationRequest(spaceId, userId, permission) { return new EntitlementRequest(spaceId, '', userId, permission); } function newChannelEntitlementEvaluationRequest(spaceId, channelId, userId, permission) { return new EntitlementRequest(spaceId, channelId, userId, permission); } function newSpaceEntitlementRequest(spaceId, permission) { return new EntitlementRequest(spaceId, '', '', permission); } function newChannelEntitlementRequest(spaceId, channelId, permission) { return new EntitlementRequest(spaceId, channelId, '', permission); } function ensureHexPrefix(value) { return value.startsWith('0x') ? value : `0x${value}`; } const EmptyXchainConfig = { supportedRpcUrls: {}, etherBasedChains: [], }; export class SpaceDapp { isLegacySpaceCache; config; provider; spaceRegistrar; pricingModules; walletLink; platformRequirements; airdrop; entitlementCache; entitledWalletCache; entitlementEvaluationCache; constructor(config, provider) { this.isLegacySpaceCache = new Map(); this.config = config; this.provider = provider; this.spaceRegistrar = new SpaceRegistrar(config, provider); this.walletLink = new WalletLink(config, provider); this.pricingModules = new PricingModules(config, provider); this.platformRequirements = new PlatformRequirements(config.addresses.spaceFactory, provider); this.airdrop = new RiverAirdropDapp(config, provider); // For RPC providers that pool for events, we need to set the polling interval to a lower value // so that we don't miss events that may be emitted in between polling intervals. The Ethers // default is 4000ms, which is based on the assumption of 12s mainnet blocktimes. if ('pollingInterval' in provider && typeof provider.pollingInterval === 'number') { provider.pollingInterval = 250; } const isLocalDev = isTestEnv() || config.chainId === LOCALHOST_CHAIN_ID; const cacheOpts = { positiveCacheTTLSeconds: isLocalDev ? 5 : 15 * 60, negativeCacheTTLSeconds: 2, }; this.entitlementCache = new EntitlementCache(cacheOpts); this.entitledWalletCache = new EntitlementCache(cacheOpts); this.entitlementEvaluationCache = new EntitlementCache(cacheOpts); } async isLegacySpace(spaceId) { const cachedValue = this.isLegacySpaceCache.get(spaceId); if (cachedValue !== undefined) { return cachedValue; } const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } // Legacy spaces do not have RuleEntitlementV2 const maybeShim = await space.findEntitlementByType(EntitlementModuleType.RuleEntitlementV2); const isLegacy = maybeShim === null; this.isLegacySpaceCache.set(spaceId, isLegacy); return isLegacy; } async addRoleToChannel(spaceId, channelNetworkId, roleId, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const channelId = ensureHexPrefix(channelNetworkId); return wrapTransaction(() => space.Channels.write(signer).addRoleToChannel(channelId, roleId), txnOpts); } async waitForRoleCreated(spaceId, txn) { const receipt = await this.provider.waitForTransaction(txn.hash); if (receipt.status === 0) { return { roleId: undefined, error: new Error('Transaction failed') }; } const parsedLogs = await this.parseSpaceLogs(spaceId, receipt.logs); const roleCreatedEvent = parsedLogs.find((log) => log?.name === 'RoleCreated'); if (!roleCreatedEvent) { return { roleId: undefined, error: new Error('RoleCreated event not found') }; } const roleId = roleCreatedEvent.args[1].toNumber(); return { roleId, error: undefined }; } async banWalletAddress(spaceId, walletAddress, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const token = await space.ERC721AQueryable.read .tokensOfOwner(walletAddress) .then((tokens) => tokens[0]); return wrapTransaction(() => space.Banning.write(signer).ban(token), txnOpts); } async unbanWalletAddress(spaceId, walletAddress, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const token = await space.ERC721AQueryable.read .tokensOfOwner(walletAddress) .then((tokens) => tokens[0]); return wrapTransaction(() => space.Banning.write(signer).unban(token), txnOpts); } async walletAddressIsBanned(spaceId, walletAddress) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const token = await space.ERC721AQueryable.read .tokensOfOwner(walletAddress) .then((tokens) => tokens[0]); return await space.Banning.read.isBanned(token); } async bannedWalletAddresses(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const bannedTokenIds = await space.Banning.read.banned(); const bannedWalletAddresses = await Promise.all(bannedTokenIds.map(async (tokenId) => await space.ERC721A.read.ownerOf(tokenId))); return bannedWalletAddresses; } async createLegacySpace(params, signer, txnOpts) { const spaceInfo = { name: params.spaceName, uri: params.uri, membership: params.membership, channel: { metadata: params.channelName || '', }, shortDescription: params.shortDescription ?? '', longDescription: params.longDescription ?? '', }; return wrapTransaction(() => this.spaceRegistrar.LegacySpaceArchitect.write(signer).createSpace(spaceInfo), txnOpts); } async createSpace(params, signer, txnOpts) { return wrapTransaction(() => { const createSpaceFunction = this.spaceRegistrar.CreateSpace.write(signer)['createSpaceWithPrepay(((string,string,string,string),((string,string,uint256,uint256,uint64,address,address,uint256,address),(bool,address[],bytes,bool),string[]),(string),(uint256)))']; return createSpaceFunction({ channel: { metadata: params.channelName || '', }, membership: params.membership, metadata: { name: params.spaceName, uri: params.uri, longDescription: params.longDescription || '', shortDescription: params.shortDescription || '', }, prepay: { supply: params.prepaySupply ?? 0, }, }); }, txnOpts); } async createChannel(spaceId, channelName, channelDescription, channelNetworkId, roleIds, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const channelId = ensureHexPrefix(channelNetworkId); return wrapTransaction(() => space.Channels.write(signer).createChannel(channelId, stringifyChannelMetadataJSON({ name: channelName, description: channelDescription, }), roleIds), txnOpts); } async createChannelWithPermissionOverrides(spaceId, channelName, channelDescription, channelNetworkId, roles, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const channelId = ensureHexPrefix(channelNetworkId); return wrapTransaction(() => space.Channels.write(signer).createChannelWithOverridePermissions(channelId, stringifyChannelMetadataJSON({ name: channelName, description: channelDescription, }), roles), txnOpts); } async legacyCreateRole(spaceId, roleName, permissions, users, ruleData, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const entitlements = await createLegacyEntitlementStruct(space, users, ruleData); return wrapTransaction(() => space.Roles.write(signer).createRole(roleName, permissions, entitlements), txnOpts); } async createRole(spaceId, roleName, permissions, users, ruleData, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const entitlements = await createEntitlementStruct(space, users, ruleData); return wrapTransaction(() => space.Roles.write(signer).createRole(roleName, permissions, entitlements), txnOpts); } async deleteRole(spaceId, roleId, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Roles.write(signer).removeRole(roleId), txnOpts); } async getChannels(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.getChannels(); } async tokenURI(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const spaceInfo = await space.getSpaceInfo(); return space.SpaceOwnerErc721A.read.tokenURI(spaceInfo.tokenId); } memberTokenURI(spaceId, tokenId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.ERC721A.read.tokenURI(tokenId); } async getChannelDetails(spaceId, channelNetworkId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const channelId = ensureHexPrefix(channelNetworkId); return space.getChannel(channelId); } async getPermissionsByRoleId(spaceId, roleId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.getPermissionsByRoleId(roleId); } async getRole(spaceId, roleId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.getRole(roleId); } async getRoles(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const roles = await space.Roles.read.getRoles(); return roles.map((role) => ({ roleId: role.id.toNumber(), name: role.name, })); } async getSpaceInfo(spaceId) { const space = this.getSpace(spaceId); if (!space) { return undefined; } const [owner, disabled, spaceInfo] = await Promise.all([ space.Ownable.read.owner(), space.Pausable.read.paused(), space.getSpaceInfo(), ]); return { address: space.Address, networkId: space.SpaceId, name: spaceInfo.name ?? '', owner, disabled, uri: spaceInfo.uri ?? '', tokenId: ethers.BigNumber.from(spaceInfo.tokenId).toString(), createdAt: ethers.BigNumber.from(spaceInfo.createdAt).toString(), shortDescription: spaceInfo.shortDescription ?? '', longDescription: spaceInfo.longDescription ?? '', }; } async updateSpaceInfo(spaceId, name, uri, shortDescription, longDescription, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.SpaceOwner.write(signer).updateSpaceInfo(space.Address, name, uri, shortDescription, longDescription), txnOpts); } async decodeEntitlementData(space, entitlementData) { const entitlements = entitlementData.map((x) => ({ entitlementType: x.entitlementType, ruleEntitlement: undefined, userEntitlement: undefined, })); const [userEntitlementShim, ruleEntitlementShim, ruleEntitlementV2Shim] = (await Promise.all([ space.findEntitlementByType(EntitlementModuleType.UserEntitlement), space.findEntitlementByType(EntitlementModuleType.RuleEntitlement), space.findEntitlementByType(EntitlementModuleType.RuleEntitlementV2), ])); for (let i = 0; i < entitlementData.length; i++) { const entitlement = entitlementData[i]; if (entitlement.entitlementType === EntitlementModuleType.RuleEntitlement) { entitlements[i].entitlementType = EntitlementModuleType.RuleEntitlement; const decodedData = ruleEntitlementShim?.decodeGetRuleData(entitlement.entitlementData); if (decodedData) { entitlements[i].ruleEntitlement = { kind: 'v1', rules: decodedData, }; } } else if (entitlement.entitlementType === EntitlementModuleType.RuleEntitlementV2) { entitlements[i].entitlementType = EntitlementModuleType.RuleEntitlementV2; const decodedData = ruleEntitlementV2Shim?.decodeGetRuleData(entitlement.entitlementData); if (decodedData) { entitlements[i].ruleEntitlement = { kind: 'v2', rules: decodedData, }; } } else if (entitlement.entitlementType === EntitlementModuleType.UserEntitlement) { entitlements[i].entitlementType = EntitlementModuleType.UserEntitlement; const decodedData = userEntitlementShim?.decodeGetAddresses(entitlement.entitlementData); if (decodedData) { entitlements[i].userEntitlement = decodedData; } } else { throw new Error('Unknown entitlement type'); } } return entitlements; } async getEntitlementsForPermission(spaceId, permission) { const { value } = await this.entitlementCache.executeUsingCache(newSpaceEntitlementRequest(spaceId, permission), async (request) => { const entitlementData = await this.getEntitlementsForPermissionUncached(request.spaceId, request.permission); return new EntitlementDataCacheResult(entitlementData); }); return value; } async getEntitlementsForPermissionUncached(spaceId, permission) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const entitlementData = await space.EntitlementDataQueryable.read.getEntitlementDataByPermission(permission); return await this.decodeEntitlementData(space, entitlementData); } async getChannelEntitlementsForPermission(spaceId, channelId, permission) { const { value } = await this.entitlementCache.executeUsingCache(newChannelEntitlementRequest(spaceId, channelId, permission), async (request) => { const entitlementData = await this.getChannelEntitlementsForPermissionUncached(request.spaceId, request.channelId, request.permission); return new EntitlementDataCacheResult(entitlementData); }); return value; } async getChannelEntitlementsForPermissionUncached(spaceId, channelId, permission) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const entitlementData = await space.EntitlementDataQueryable.read.getChannelEntitlementDataByPermission(channelId, permission); return await this.decodeEntitlementData(space, entitlementData); } async getLinkedWallets(wallet) { let linkedWallets = await this.walletLink.getLinkedWallets(wallet); // If there are no linked wallets, consider that the wallet may be linked to another root key. if (linkedWallets.length === 0) { const possibleRoot = await this.walletLink.getRootKeyForWallet(wallet); if (possibleRoot !== INVALID_ADDRESS) { linkedWallets = await this.walletLink.getLinkedWallets(possibleRoot); return [possibleRoot, ...linkedWallets]; } } return [wallet, ...linkedWallets]; } async getLinkedWalletsWithDelegations(wallet) { let linkedWallets = await this.walletLink.getLinkedWalletsWithDelegations(wallet); // If there are no linked wallets, consider that the wallet may be linked to another root key. if (linkedWallets.length === 0) { const possibleRoot = await this.walletLink.getRootKeyForWallet(wallet); if (possibleRoot !== INVALID_ADDRESS) { linkedWallets = await this.walletLink.getLinkedWalletsWithDelegations(possibleRoot); return [possibleRoot, ...linkedWallets]; } } return [wallet, ...linkedWallets]; } async evaluateEntitledWallet(rootKey, allWallets, entitlements, xchainConfig) { const isEveryOneSpace = entitlements.some((e) => e.userEntitlement?.includes(EVERYONE_ADDRESS)); if (isEveryOneSpace) { return rootKey; } // Evaluate all user entitlements first, as they do not require external calls. for (const entitlement of entitlements) { for (const user of allWallets) { if (entitlement.userEntitlement?.includes(user)) { return user; } } } // Accumulate all RuleDataV1 entitlements and convert to V2s. const ruleEntitlements = entitlements .filter((x) => x.entitlementType === EntitlementModuleType.RuleEntitlement && x.ruleEntitlement?.kind == 'v1') .map((x) => convertRuleDataV1ToV2(x.ruleEntitlement.rules)); // Add all RuleDataV2 entitlements. ruleEntitlements.push(...entitlements .filter((x) => x.entitlementType === EntitlementModuleType.RuleEntitlementV2 && x.ruleEntitlement?.kind == 'v2') .map((x) => x.ruleEntitlement.rules)); return await Promise.any(ruleEntitlements.map(async (ruleData) => { if (!ruleData) { throw new Error('Rule data not found'); } const operations = ruleDataToOperations(ruleData); const result = await evaluateOperationsForEntitledWallet(operations, allWallets, xchainConfig); if (result !== ethers.constants.AddressZero) { return result; } // This is not a true error, but is used here so that the Promise.any will not // resolve with an unentitled wallet. throw new NoEntitledWalletError(); })).catch(NoEntitledWalletError.throwIfRuntimeErrors); } /** * Checks if user has a wallet entitled to join a space based on the minter role rule entitlements */ async getEntitledWalletForJoiningSpace(spaceId, rootKey, xchainConfig) { const { value } = await this.entitledWalletCache.executeUsingCache(newSpaceEntitlementEvaluationRequest(spaceId, rootKey, Permission.JoinSpace), async (request) => { const entitledWallet = await this.getEntitledWalletForJoiningSpaceUncached(request.spaceId, request.userId, xchainConfig); return new EntitledWalletCacheResult(entitledWallet); }); return value; } async getEntitledWalletForJoiningSpaceUncached(spaceId, rootKey, xchainConfig) { const allWallets = await this.getLinkedWalletsWithDelegations(rootKey); const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const owner = await space.Ownable.read.owner(); // Space owner is entitled to all channels if (allWallets.includes(owner)) { return owner; } const bannedWallets = await this.bannedWalletAddresses(spaceId); for (const wallet of allWallets) { if (bannedWallets.includes(wallet)) { return; } } const entitlements = await this.getEntitlementsForPermission(spaceId, Permission.JoinSpace); return await this.evaluateEntitledWallet(rootKey, allWallets, entitlements, xchainConfig); } async isEntitledToSpace(spaceId, user, permission) { const { value } = await this.entitlementEvaluationCache.executeUsingCache(newSpaceEntitlementEvaluationRequest(spaceId, user, permission), async (request) => { const isEntitled = await this.isEntitledToSpaceUncached(request.spaceId, request.userId, request.permission); return new BooleanCacheResult(isEntitled); }); return value; } async isEntitledToSpaceUncached(spaceId, user, permission) { const space = this.getSpace(spaceId); if (!space) { return false; } if (permission === Permission.JoinSpace) { throw new Error('use getEntitledWalletForJoiningSpace instead of isEntitledToSpace'); } return space.Entitlements.read.isEntitledToSpace(user, permission); } async isEntitledToChannel(spaceId, channelNetworkId, user, permission, xchainConfig = EmptyXchainConfig) { const { value } = await this.entitlementEvaluationCache.executeUsingCache(newChannelEntitlementEvaluationRequest(spaceId, channelNetworkId, user, permission), async (request) => { const isEntitled = await this.isEntitledToChannelUncached(request.spaceId, request.channelId, request.userId, request.permission, xchainConfig); return new BooleanCacheResult(isEntitled); }); return value; } async isEntitledToChannelUncached(spaceId, channelNetworkId, user, permission, xchainConfig) { const space = this.getSpace(spaceId); if (!space) { return false; } const channelId = ensureHexPrefix(channelNetworkId); const linkedWallets = await this.getLinkedWalletsWithDelegations(user); const owner = await space.Ownable.read.owner(); // Space owner is entitled to all channels if (linkedWallets.includes(owner)) { return true; } const bannedWallets = await this.bannedWalletAddresses(spaceId); for (const wallet of linkedWallets) { if (bannedWallets.includes(wallet)) { return false; } } const entitlements = await this.getChannelEntitlementsForPermission(spaceId, channelId, permission); const entitledWallet = await this.evaluateEntitledWallet(user, linkedWallets, entitlements, xchainConfig); return entitledWallet !== undefined; } parseSpaceFactoryError(error) { if (!this.spaceRegistrar.SpaceArchitect) { throw new Error('SpaceArchitect is not deployed properly.'); } const decodedErr = this.spaceRegistrar.SpaceArchitect.parseError(error); logger.error(decodedErr); return decodedErr; } parseSpaceError(spaceId, error) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const decodedErr = space.parseError(error); logger.error(decodedErr); return decodedErr; } /** * Attempts to parse an error against all contracts * If you're error is not showing any data with this call, make sure the contract is listed either in parseSpaceError or nonSpaceContracts * @param args * @returns */ parseAllContractErrors(args) { let err; if (args.spaceId) { err = this.parseSpaceError(args.spaceId, args.error); } if (err && err?.name !== UNKNOWN_ERROR) { return err; } err = this.spaceRegistrar.SpaceArchitect.parseError(args.error); if (err?.name !== UNKNOWN_ERROR) { return err; } const nonSpaceContracts = [this.pricingModules, this.walletLink]; for (const contract of nonSpaceContracts) { err = contract.parseError(args.error); if (err?.name !== UNKNOWN_ERROR) { return err; } } return err; } async parseSpaceLogs(spaceId, logs) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return logs.map((spaceLog) => { try { return space.parseLog(spaceLog); } catch (err) { logger.error(err); return; } }); } async updateChannel(params, signer, txnOpts) { const space = this.getSpace(params.spaceId); if (!space) { throw new Error(`Space with spaceId "${params.spaceId}" is not found.`); } const encodedCallData = await this.encodedUpdateChannelData(space, params); return wrapTransaction(() => space.Multicall.write(signer).multicall(encodedCallData), txnOpts); } async encodedUpdateChannelData(space, params) { const channelId = ensureHexPrefix(params.channelId); if (isUpdateChannelStatusParams(params)) { // When enabling or disabling channels, passing names and roles is not required. // To ensure the contract accepts this exception, the metadata argument should be left empty. return [ space.Channels.interface.encodeFunctionData('updateChannel', [channelId, '', true]), ]; } // data for the multicall const encodedCallData = []; // update the channel metadata encodedCallData.push(space.Channels.interface.encodeFunctionData('updateChannel', [ channelId, stringifyChannelMetadataJSON({ name: params.channelName, description: params.channelDescription, }), params.disabled ?? false, // default to false ])); // update any channel role changes const encodedUpdateChannelRoles = await this.encodeUpdateChannelRoles(space, params.channelId, params.roleIds); for (const callData of encodedUpdateChannelRoles) { encodedCallData.push(callData); } return encodedCallData; } async removeChannel(params, signer, txnOpts) { const space = this.getSpace(params.spaceId); if (!space) { throw new Error(`Space with spaceId "${params.spaceId}" is not found.`); } return wrapTransaction(() => space.Channels.write(signer).removeChannel(params.channelId), txnOpts); } async legacyUpdateRole(params, signer, txnOpts) { const space = this.getSpace(params.spaceNetworkId); if (!space) { throw new Error(`Space with spaceId "${params.spaceNetworkId}" is not found.`); } const updatedEntitlemets = await this.createLegacyUpdatedEntitlements(space, params); return wrapTransaction(() => space.Roles.write(signer).updateRole(params.roleId, params.roleName, params.permissions, updatedEntitlemets), txnOpts); } async updateRole(params, signer, txnOpts) { const space = this.getSpace(params.spaceNetworkId); if (!space) { throw new Error(`Space with spaceId "${params.spaceNetworkId}" is not found.`); } const updatedEntitlemets = await this.createUpdatedEntitlements(space, params); return wrapTransaction(() => space.Roles.write(signer).updateRole(params.roleId, params.roleName, params.permissions, updatedEntitlemets), txnOpts); } async getChannelPermissionOverrides(spaceId, roleId, channelNetworkId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const channelId = ensureHexPrefix(channelNetworkId); return (await space.Roles.read.getChannelPermissionOverrides(roleId, channelId)).filter(isPermission); } async setChannelPermissionOverrides(params, signer, txnOpts) { const space = this.getSpace(params.spaceNetworkId); if (!space) { throw new Error(`Space with spaceId "${params.spaceNetworkId}" is not found.`); } const channelId = ensureHexPrefix(params.channelId); return wrapTransaction(() => space.Roles.write(signer).setChannelPermissionOverrides(params.roleId, channelId, params.permissions), txnOpts); } async clearChannelPermissionOverrides(params, signer, txnOpts) { const space = this.getSpace(params.spaceNetworkId); if (!space) { throw new Error(`Space with spaceId "${params.spaceNetworkId}" is not found.`); } const channelId = ensureHexPrefix(params.channelId); return wrapTransaction(() => space.Roles.write(signer).clearChannelPermissionOverrides(params.roleId, channelId), txnOpts); } async setSpaceAccess(spaceId, disabled, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } if (disabled) { return wrapTransaction(() => space.Pausable.write(signer).pause(), txnOpts); } else { return wrapTransaction(() => space.Pausable.write(signer).unpause(), txnOpts); } } /** * * @param spaceId * @param priceInWei * @param signer */ async setMembershipPrice(spaceId, priceInWei, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Membership.write(signer).setMembershipPrice(priceInWei), txnOpts); } async setMembershipPricingModule(spaceId, pricingModule, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Membership.write(signer).setMembershipPricingModule(pricingModule), txnOpts); } async setMembershipLimit(spaceId, limit, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Membership.write(signer).setMembershipLimit(limit), txnOpts); } async setMembershipFreeAllocation(spaceId, freeAllocation, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Membership.write(signer).setMembershipFreeAllocation(freeAllocation), txnOpts); } async prepayMembership(spaceId, supply, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const cost = await space.Prepay.read.calculateMembershipPrepayFee(supply); return wrapTransaction(() => space.Prepay.write(signer).prepayMembership(supply, { value: cost, }), txnOpts); } async getPrepaidMembershipSupply(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Prepay.read.prepaidMembershipSupply(); } async setChannelAccess(spaceId, channelNetworkId, disabled, signer, txnOpts) { const channelId = ensureHexPrefix(channelNetworkId); const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Channels.write(signer).updateChannel(channelId, '', disabled), txnOpts); } async getSpaceMembershipTokenAddress(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Membership.address; } async getJoinSpacePriceDetails(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } // membershipPrice is either the maximum of either the price set during space creation, or the PlatformRequirements membership fee // it will alawys be a value regardless of whether the space has free allocations or prepaid memberships const membershipPrice = await space.Membership.read.getMembershipPrice(); // totalSupply = number of memberships minted const totalSupply = await space.ERC721A.read.totalSupply(); // free allocation is set at space creation and is unchanging - it neither increases nor decreases // if totalSupply < freeAllocation, the contracts won't charge for minting a membership nft, // else it will charge the membershipPrice const freeAllocation = await this.getMembershipFreeAllocation(spaceId); // prepaidSupply = number of additional prepaid memberships // if any prepaid memberships have been purchased, the contracts won't charge for minting a membership nft, // else it will charge the membershipPrice const prepaidSupply = await space.Prepay.read.prepaidMembershipSupply(); // remainingFreeSupply // if totalSupply < freeAllocation, freeAllocation + prepaid - minted memberships // else the remaining prepaidSupply if any const remainingFreeSupply = totalSupply.lt(freeAllocation) ? freeAllocation.add(prepaidSupply).sub(totalSupply) : prepaidSupply; return { price: remainingFreeSupply.gt(0) ? ethers.BigNumber.from(0) : membershipPrice, prepaidSupply, remainingFreeSupply, }; } async getMembershipFreeAllocation(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Membership.read.getMembershipFreeAllocation(); } async joinSpace(spaceId, recipient, signer, txnOpts) { const joinSpaceStart = Date.now(); logger.log('joinSpace result before wrap', spaceId); const getSpaceStart = Date.now(); const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const issuedListener = space.Membership.listenForMembershipToken(recipient); const blockNumber = await space.provider?.getBlockNumber(); logger.log('joinSpace before blockNumber', Date.now() - getSpaceStart, blockNumber); const getPriceStart = Date.now(); const { price } = await this.getJoinSpacePriceDetails(spaceId); logger.log('joinSpace getMembershipPrice', Date.now() - getPriceStart); const wrapStart = Date.now(); const result = await wrapTransaction(async () => { // Set gas limit instead of using estimateGas // As the estimateGas is not reliable for this contract return await space.Membership.write(signer).joinSpace(recipient, { gasLimit: 1_500_000, value: price, }); }, txnOpts); const blockNumberAfterTx = await space.provider?.getBlockNumber(); logger.log('joinSpace wrap', Date.now() - wrapStart, blockNumberAfterTx); const issued = await issuedListener; const blockNumberAfter = await space.provider?.getBlockNumber(); logger.log('joinSpace after blockNumber', Date.now() - joinSpaceStart, blockNumberAfter, result, issued); return issued; } async hasSpaceMembership(spaceId, address) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Membership.hasMembership(address); } async getMembershipSupply(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const totalSupply = await space.ERC721A.read.totalSupply(); return { totalSupply: totalSupply.toNumber() }; } async getMembershipInfo(spaceId) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const [joinSpacePriceDetails, limit, currency, feeRecipient, duration, totalSupply, pricingModule,] = await Promise.all([ this.getJoinSpacePriceDetails(spaceId), space.Membership.read.getMembershipLimit(), space.Membership.read.getMembershipCurrency(), space.Ownable.read.owner(), space.Membership.read.getMembershipDuration(), space.ERC721A.read.totalSupply(), space.Membership.read.getMembershipPricingModule(), ]); const { price, prepaidSupply, remainingFreeSupply } = joinSpacePriceDetails; return { price, // keep as BigNumber (wei) maxSupply: limit.toNumber(), currency: currency, feeRecipient: feeRecipient, duration: duration.toNumber(), totalSupply: totalSupply.toNumber(), pricingModule: pricingModule, prepaidSupply: prepaidSupply.toNumber(), remainingFreeSupply: remainingFreeSupply.toNumber(), }; } getWalletLink() { return this.walletLink; } getSpace(spaceId) { return this.spaceRegistrar.getSpace(spaceId); } listPricingModules() { return this.pricingModules.listPricingModules(); } async encodeUpdateChannelRoles(space, channelNetworkId, _updatedRoleIds) { const channelId = ensureHexPrefix(channelNetworkId); const encodedCallData = []; const [channelInfo] = await Promise.all([ space.Channels.read.getChannel(channelId), space.getEntitlementShims(), ]); const currentRoleIds = new Set(channelInfo.roleIds.map((r) => r.toNumber())); const updatedRoleIds = new Set(_updatedRoleIds); const rolesToRemove = []; const rolesToAdd = []; for (const r of updatedRoleIds) { // if the current role IDs does not have the updated role ID, then that role should be added. if (!currentRoleIds.has(r)) { rolesToAdd.push(r); } } for (const r of currentRoleIds) { // if the updated role IDs no longer have the current role ID, then that role should be removed. if (!updatedRoleIds.has(r)) { rolesToRemove.push(r); } } // encode the call data for each role to remove const encodedRemoveRoles = this.encodeRemoveRolesFromChannel(space, channelId, rolesToRemove); for (const callData of encodedRemoveRoles) { encodedCallData.push(callData); } // encode the call data for each role to add const encodedAddRoles = this.encodeAddRolesToChannel(space, channelId, rolesToAdd); for (const callData of encodedAddRoles) { encodedCallData.push(callData); } return encodedCallData; } encodeAddRolesToChannel(space, channelNetworkId, roleIds) { const channelId = ensureHexPrefix(channelNetworkId); const encodedCallData = []; for (const roleId of roleIds) { const encodedBytes = space.Channels.interface.encodeFunctionData('addRoleToChannel', [ channelId, roleId, ]); encodedCallData.push(encodedBytes); } return encodedCallData; } encodeRemoveRolesFromChannel(space, channelNetworkId, roleIds) { const channelId = ensureHexPrefix(channelNetworkId); const encodedCallData = []; for (const roleId of roleIds) { const encodedBytes = space.Channels.interface.encodeFunctionData('removeRoleFromChannel', [channelId, roleId]); encodedCallData.push(encodedBytes); } return encodedCallData; } async createLegacyUpdatedEntitlements(space, params) { return createLegacyEntitlementStruct(space, params.users, params.ruleData); } async createUpdatedEntitlements(space, params) { return createEntitlementStruct(space, params.users, params.ruleData); } async refreshMetadata(spaceId, signer, txnOpts) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return wrapTransaction(() => space.Membership.metadata.write(signer).refreshMetadata(), txnOpts); } /** * Get the space address from the receipt and sender address * @param receipt - The receipt from the transaction * @param senderAddress - The address of the sender. Required for the case of a receipt containing multiple events of the same type. * @returns The space address or undefined if the receipt is not successful */ getSpaceAddress(receipt, senderAddress) { if (receipt.status !== 1) { return undefined; } for (const receiptLog of receipt.logs) { const spaceAddress = this.spaceRegistrar.SpaceArchitect.getSpaceAddressFromLog(receiptLog, senderAddress); if (spaceAddress) { return spaceAddress; } } return undefined; } getTipEvent(spaceId, receipt, senderAddress) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Tipping.getTipEvent(receipt, senderAddress); } withdrawSpaceFunds(spaceId, recipient, signer) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Membership.write(signer).withdraw(recipient); } // If the caller doesn't provide an abort controller, listenForMembershipToken will create one listenForMembershipEvent(spaceId, receiver, abortController) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Membership.listenForMembershipToken(receiver, abortController); } /** * Get the token id for the owner * Returns the first token id matched from the linked wallets of the owner * @param spaceId - The space id * @param owner - The owner * @returns The token id */ async getTokenIdOfOwner(spaceId, owner) { const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } const linkedWallets = await this.getLinkedWalletsWithDelegations(owner); const tokenIds = await space.getTokenIdsOfOwner(linkedWallets); return tokenIds[0]; } /** * Tip a user * @param args * @param args.spaceId - The space id * @param args.tokenId - The token id to tip. Obtainable from getTokenIdOfOwner * @param args.currency - The currency to tip - address or 0xEeeeeeeeee... for native currency * @param args.amount - The amount to tip * @param args.messageId - The message id - needs to be hex encoded to 64 characters * @param args.channelId - The channel id - needs to be hex encoded to 64 characters * @param signer - The signer to use for the tip * @returns The transaction */ async tip(args, signer) { const { spaceId, tokenId, currency, amount, messageId, channelId, receiver } = args; const space = this.getSpace(spaceId); if (!space) { throw new Error(`Space with spaceId "${spaceId}" is not found.`); } return space.Tipping.write(signer).tip({ receiver, tokenId, currency, amount, messageId: ensureHexPrefix(messageId), channelId: ensureHexPrefix(channelId), }, { value: amount, }); } } // Retry submitting the transaction N times (3 by default in jest, 0 by default elsewhere) // and then wait until the first confirmation of the transaction has been mined // works around gas estimation issues and other transient issues that are more common in running CI tests // so by default we only retry when running under jest // this wrapper unifies all of the wrapped contract calls in behvior, they don't return until // the transaction is confirmed async function wrapTransaction(txFn, txnOpts) { const retryLimit = txnOpts?.retryCount ?? isTestEnv() ? 3 : 0; const runTx = async () => { let retryCount = 0; // eslint-disable-next-line no-constant-condition while (true) { try { const txStart = Date.now(); const tx = await txFn(); logger.log('Transaction submitted in', Date.now() - txStart); const startConfirm = Date.now(); await confirmTransaction(tx); logger.log('Transaction confirmed in', Date.now() - startConfirm); // return the transaction, as it was successful // the caller can wait() on it again if they want to wait for more confirmations return tx; } catch (error) { retryCount++; if (retryCount >= retryLimit) { throw new Error('Transaction failed after retries: ' + error.message);