@river-build/web3
Version:
Dapps for our Space and Registry contracts
1,089 lines (1,088 loc) • 52.6 kB
JavaScript
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);