@minatokens/nft
Version:
Mina NFT Standard draft library
602 lines (563 loc) • 20.4 kB
text/typescript
/**
* The `NFTAdvancedAdminContract` is an implementation of an admin contract that uses a whitelist to control access to certain actions within the NFT ecosystem.
* This contract ensures that only whitelisted addresses can perform specific actions such as minting, updating, transferring, buying, or selling NFTs.
* It also introduces functionality for pausing and resuming the contract, upgrading the contract's verification key, and transferring ownership.
*/
import {
Bool,
DeployArgs,
method,
Permissions,
PublicKey,
SmartContract,
State,
state,
VerificationKey,
UInt64,
Provable,
Field,
AccountUpdate,
Mina,
UInt32,
Struct,
} from "o1js";
import { Whitelist } from "@minatokens/storage";
import {
MintRequest,
NFTState,
NFTAdminBase,
MintParamsOption,
PauseEvent,
PausableContract,
OwnershipChangeEvent,
OwnableContract,
TransferEvent,
} from "../interfaces/index.js";
import {
UpgradeAuthorityBase,
VerificationKeyUpgradeData,
UpgradableContract,
UpgradeAuthorityContractConstructor,
} from "@minatokens/upgradable";
export { NFTAdvancedAdminContract, AdminData, NFTAdvancedAdminDeployProps };
/**
* Deployment properties for the `NFTAdvancedAdminContract`.
*/
interface NFTAdvancedAdminDeployProps extends Exclude<DeployArgs, undefined> {
/** The public key of the admin or owner of the contract. */
admin: PublicKey;
/** The public key of the Upgrade Authority Contract. */
upgradeAuthority: PublicKey;
/** The whitelist. */
whitelist: Whitelist;
/** The URI of the zkApp. */
uri: string;
/** The admin data. */
adminData: AdminData;
}
/**
* Represents pause-related data, containing flags for pause functionality.
*/
class AdminData extends Struct({
/** Indicates whether the contract can be paused. */
canPause: Bool,
/** Indicates whether the contract is currently paused. */
isPaused: Bool,
/** Indicates whether the contract can change the royalty fee. */
allowChangeRoyalty: Bool,
/** Indicates whether the contract can change the transfer fee. */
allowChangeTransferFee: Bool,
/** Indicates whether the contract can change the base URI. */
allowChangeBaseUri: Bool,
/** Indicates whether the contract can change the creator. */
allowChangeCreator: Bool,
/** Indicates whether the contract can change the admin. */
allowChangeAdmin: Bool,
/** Indicates whether the contract can change the name. */
allowChangeName: Bool,
}) {
static new(
params: {
canPause?: boolean;
isPaused?: boolean;
allowChangeRoyalty?: boolean;
allowChangeTransferFee?: boolean;
allowChangeBaseUri?: boolean;
allowChangeCreator?: boolean;
allowChangeAdmin?: boolean;
allowChangeName?: boolean;
} = {}
) {
const {
canPause,
isPaused,
allowChangeRoyalty,
allowChangeTransferFee,
allowChangeBaseUri,
allowChangeCreator,
allowChangeAdmin,
allowChangeName,
} = params;
return new AdminData({
canPause: Bool(canPause ?? true),
isPaused: Bool(isPaused ?? false),
allowChangeRoyalty: Bool(allowChangeRoyalty ?? false),
allowChangeTransferFee: Bool(allowChangeTransferFee ?? false),
allowChangeBaseUri: Bool(allowChangeBaseUri ?? false),
allowChangeCreator: Bool(allowChangeCreator ?? false),
allowChangeAdmin: Bool(allowChangeAdmin ?? false),
allowChangeName: Bool(allowChangeName ?? false),
});
}
/**
* Packs the pause data into a `Field`.
* @returns A `Field` representing the packed pause data.
*/
pack(): Field {
return Field.fromBits([
this.isPaused,
this.canPause,
this.allowChangeRoyalty,
this.allowChangeTransferFee,
this.allowChangeBaseUri,
this.allowChangeCreator,
this.allowChangeAdmin,
this.allowChangeName,
]);
}
/**
* Unpacks a `Field` into `PauseData`.
* @param field The `Field` to unpack.
* @returns An instance of `PauseData`.
*/
static unpack(field: Field): AdminData {
const [
isPaused,
canPause,
allowChangeRoyalty,
allowChangeTransferFee,
allowChangeBaseUri,
allowChangeCreator,
allowChangeAdmin,
allowChangeName,
] = field.toBits(8);
return new AdminData({
canPause,
isPaused,
allowChangeRoyalty,
allowChangeTransferFee,
allowChangeBaseUri,
allowChangeCreator,
allowChangeAdmin,
allowChangeName,
});
}
static isPaused(field: Field): Bool {
return field.toBits(8)[0];
}
}
const NFTAdvancedAdminContractErrors = {
contractIsPaused: "Contract is paused",
notWhitelisted: "Address not whitelisted",
senderNotWhitelisted: "Sender address not whitelisted",
cannotMint: "Cannot mint",
verificationKeyHashNotFound: "Verification key hash not found",
cannotUpgradeVerificationKey: "Cannot upgrade verification key",
};
/**
* Constructs the `NFTAdvancedAdmin` class, an admin contract that uses a whitelist to control access.
* @param params Object containing the upgrade contract constructor.
* @returns The `NFTAdvancedAdmin` class.
*/
function NFTAdvancedAdminContract(params: {
upgradeContract: UpgradeAuthorityContractConstructor;
}) {
const { upgradeContract } = params;
/**
* The `NFTWhitelistedAdmin` class ensures that only whitelisted addresses can perform specific actions such as minting, updating, transferring, buying, or selling NFTs.
* It also provides functionality for pausing and resuming the contract, upgrading the contract's verification key, and transferring ownership.
*/
class NFTAdvancedAdmin
extends SmartContract
implements
NFTAdminBase,
UpgradableContract,
PausableContract,
OwnableContract
{
/** The public key of the admin or owner of the contract. */
admin = State<PublicKey>();
/** The public key of the Upgrade Authority Contract. */
upgradeAuthority = State<PublicKey>();
/** The root hash of the Merkle tree representing the whitelist. */
whitelist = State<Whitelist>();
/** Packed field containing pause-related flags. */
data = State<Field>();
/**
* Deploys the `NFTWhitelistedAdmin` contract with the provided initial settings.
* @param props Deployment properties.
*/
async deploy(props: NFTAdvancedAdminDeployProps) {
await super.deploy(props);
this.admin.set(props.admin);
this.upgradeAuthority.set(props.upgradeAuthority);
this.whitelist.set(props.whitelist);
this.data.set(props.adminData.pack());
this.account.zkappUri.set(props.uri);
this.account.permissions.set({
...Permissions.default(),
// Allow the upgrade authority to set the verification key
// even when there is no protocol upgrade
setVerificationKey:
Permissions.VerificationKey.proofDuringCurrentVersion(),
setPermissions: Permissions.impossible(),
access: Permissions.proof(),
send: Permissions.proof(),
setZkappUri: Permissions.impossible(),
setTokenSymbol: Permissions.impossible(),
});
}
events = {
/** Emitted when the contract's verification key is upgraded. */
upgradeVerificationKey: Field,
/** Emitted when the contract is paused. */
pause: PauseEvent,
/** Emitted when the contract is resumed. */
resume: PauseEvent,
/** Emitted when ownership of the contract changes. */
ownershipChange: OwnershipChangeEvent,
/** Emitted when the whitelist is updated. */
updateWhitelist: Whitelist,
};
/**
* Ensures that the transaction is authorized by the contract owner.
* @returns An `AccountUpdate` representing the admin's signed transaction.
*/
async ensureOwnerSignature(): Promise<AccountUpdate> {
const admin = this.admin.getAndRequireEquals();
const adminUpdate = AccountUpdate.createSigned(admin);
adminUpdate.body.useFullCommitment = Bool(true); // prevent memo and fee change
return adminUpdate;
}
/** Gets the upgrade contract constructor. */
get getUpgradeContractConstructor() {
return upgradeContract;
}
/**
* Retrieves the `UpgradeAuthorityBase` contract instance.
* @returns An instance of the upgrade authority contract.
*/
async getUpgradeContract(): Promise<UpgradeAuthorityBase> {
return new this.getUpgradeContractConstructor(
this.upgradeAuthority.getAndRequireEquals()
);
}
/**
* Upgrades the contract's verification key using the Upgrade Authority Contract.
* @param vk The new verification key.
*/
async upgradeVerificationKey(vk: VerificationKey) {
await this.ensureOwnerSignature();
const upgradeContract = await this.getUpgradeContract();
// fetchAccount() should be called before calling this method
// this code should be changed after verification key precondition
// will be added to the Mina protocol
const previousVerificationKeyHash = Provable.witness(Field, () => {
const account = Mina.getAccount(this.address);
const vkHash = account.zkapp?.verificationKey?.hash;
if (!vkHash) {
throw Error(
NFTAdvancedAdminContractErrors.verificationKeyHashNotFound
);
}
return vkHash;
});
const data = new VerificationKeyUpgradeData({
address: this.address,
tokenId: this.tokenId,
previousVerificationKeyHash,
newVerificationKeyHash: vk.hash,
});
const upgradeAuthorityAnswer = await upgradeContract.verifyUpgradeData(
data
);
upgradeAuthorityAnswer.isVerified.assertTrue(
NFTAdvancedAdminContractErrors.cannotUpgradeVerificationKey
);
this.account.verificationKey.set(vk);
this.upgradeAuthority.set(
upgradeAuthorityAnswer.nextUpgradeAuthority.orElse(
this.upgradeAuthority.getAndRequireEquals()
)
);
this.emitEvent("upgradeVerificationKey", vk.hash);
}
/**
* Determines if the minting request can proceed by checking if the owner and sender are whitelisted.
* @param mintRequest The minting request parameters.
* @returns A `MintParamsOption` indicating if minting is allowed.
*/
.returns(MintParamsOption)
async canMint(mintRequest: MintRequest): Promise<MintParamsOption> {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
const whitelist = this.whitelist.getAndRequireEquals();
const ownerAmount = await whitelist.getWhitelistedAmount(
mintRequest.owner
);
ownerAmount.isSome.assertTrue(
NFTAdvancedAdminContractErrors.notWhitelisted
);
const sender = this.sender.getUnconstrained();
const senderUpdate = AccountUpdate.createSigned(sender);
senderUpdate.body.useFullCommitment = Bool(true); // prevent memo and fee change
const senderAmount = await whitelist.getWhitelistedAmount(sender);
senderAmount.isSome.assertTrue(
NFTAdvancedAdminContractErrors.senderNotWhitelisted
);
const mintParams = await Provable.witnessAsync(
MintParamsOption,
async () => {
// only creator can mint
// can be changed in the future to support CMS
return MintParamsOption.none();
}
);
return mintParams;
}
/**
* Checks whether the NFT's state can be updated, ensuring the new owner is whitelisted.
* @param input The current state of the NFT.
* @param output The desired new state of the NFT.
* @returns A `Bool` indicating whether the update is permitted.
*/
.returns(Bool)
async canUpdate(input: NFTState, output: NFTState) {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
const whitelist = this.whitelist.getAndRequireEquals();
return (await whitelist.getWhitelistedAmount(output.owner)).isSome.and(
(await whitelist.getWhitelistedAmount(input.owner)).isSome
);
}
/**
* Verifies if the transfer between `from` and `to` addresses is allowed based on whitelist status.
* @param address The address of the NFT.
* @param from The sender's public key.
* @param to The receiver's public key.
* @returns A `Bool` indicating whether the transfer is permitted.
*/
.returns(Bool)
async canTransfer(transferEvent: TransferEvent) {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
const { to, from, price } = transferEvent;
const whitelist = this.whitelist.getAndRequireEquals();
const toAmount = await whitelist.getWhitelistedAmount(to);
const fromAmount = await whitelist.getWhitelistedAmount(from);
const toAmountAllowed = toAmount
.orElse(UInt64.from(0))
.greaterThanOrEqual(price.orElse(UInt64.zero));
const fromAmountAllowed = fromAmount
.orElse(UInt64.from(0))
.greaterThanOrEqual(price.orElse(UInt64.zero));
return toAmountAllowed
.and(fromAmountAllowed)
.and(toAmount.isSome)
.and(fromAmount.isSome);
}
/**
* Updates the whitelist's Merkle root and the associated off-chain storage reference.
* @param whitelistRoot The new whitelist root.
* @param storage The storage reference for the whitelist data.
*/
async updateWhitelist(whitelist: Whitelist) {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
await this.ensureOwnerSignature();
this.whitelist.set(whitelist);
this.emitEvent("updateWhitelist", whitelist);
}
/**
* Pauses the contract, preventing certain administrative actions from being performed.
*/
async pause(): Promise<void> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.canPause.assertTrue();
adminData.isPaused = Bool(true);
this.data.set(adminData.pack());
this.emitEvent("pause", new PauseEvent({ isPaused: Bool(true) }));
}
/**
* Resumes the contract, allowing administrative actions to be performed again.
*/
async resume(): Promise<void> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.canPause.assertTrue();
adminData.isPaused = Bool(false);
this.data.set(adminData.pack());
this.emitEvent("resume", new PauseEvent({ isPaused: Bool(false) }));
}
/**
* Transfers ownership of the contract to a new admin.
* @param newOwner The public key of the new admin.
* @returns The public key of the old owner.
*/
.returns(PublicKey)
async transferOwnership(to: PublicKey): Promise<PublicKey> {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
await this.ensureOwnerSignature();
const from = this.admin.getAndRequireEquals();
this.admin.set(to);
this.emitEvent(
"ownershipChange",
new OwnershipChangeEvent({
from,
to,
})
);
return from;
}
.returns(Bool)
async canChangeVerificationKey(
vk: VerificationKey,
address: PublicKey,
tokenId: Field
): Promise<Bool> {
AdminData.isPaused(this.data.getAndRequireEquals()).assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
const upgradeContract = await this.getUpgradeContract();
// fetchAccount() should be called before calling this method
// TODO: this code should be changed after verification key precondition
// will be added to the Mina protocol
const previousVerificationKeyHash = Provable.witness(Field, () => {
const account = Mina.getAccount(address, tokenId);
const vkHash = account.zkapp?.verificationKey?.hash;
if (!vkHash) {
throw Error("Verification key hash not found");
}
return vkHash;
});
const data = new VerificationKeyUpgradeData({
address: address,
tokenId: tokenId,
previousVerificationKeyHash,
newVerificationKeyHash: vk.hash,
});
const upgradeAuthorityAnswer = await upgradeContract.verifyUpgradeData(
data
);
return upgradeAuthorityAnswer.isVerified;
}
/**
* Determines if the name can be changed for a Collection.
*/
.returns(Bool)
async canChangeName(name: Field): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeName;
}
/**
* Determines if the creator can be changed for a Collection.
*/
.returns(Bool)
async canChangeCreator(creator: PublicKey): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeCreator;
}
/**
* Determines if the base URI can be changed for a Collection.
*/
.returns(Bool)
async canChangeBaseUri(baseUri: Field): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeBaseUri;
}
/**
* Determines if the royalty fee can be changed for a Collection.
*/
.returns(Bool)
async canChangeRoyalty(royaltyFee: UInt32): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeRoyalty;
}
/**
* Determines if the transfer fee can be changed for a Collection.
*/
.returns(Bool)
async canChangeTransferFee(transferFee: UInt64): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeTransferFee;
}
/**
* Determines if the admin contract can be changed for a Collection.
*/
.returns(Bool)
async canSetAdmin(admin: PublicKey): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.allowChangeAdmin;
}
/**
* Determines if the collection can be paused.
*/
.returns(Bool)
async canPause(): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.canPause;
}
/**
* Determines if the collection can be resumed.
*/
.returns(Bool)
async canResume(): Promise<Bool> {
await this.ensureOwnerSignature();
const adminData = AdminData.unpack(this.data.getAndRequireEquals());
adminData.isPaused.assertFalse(
NFTAdvancedAdminContractErrors.contractIsPaused
);
return adminData.canPause;
}
}
return NFTAdvancedAdmin;
}