Last active
July 18, 2021 02:48
-
-
Save kprimice/6801efca7acbfe4890b0fbd73e525381 to your computer and use it in GitHub Desktop.
This contract includes: PST (default token), multiple NFTs handling, NFT profit sharing (royalties), multiple editions NFTs (no.), third party approval, batch transfers, multiple contract owners (two levels) and foreignInvoke
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare function ContractAssert(cond: any, e: string): asserts cond; | |
declare function ContractError(e: string): void; | |
import { SmartWeaveGlobal } from "smartweave/lib/smartweave-global"; | |
declare const SmartWeave: SmartWeaveGlobal; | |
const PST = "PTY" | |
const UNITY = 1e12 | |
const ERR_404TOKENID = "No token found: Invalid tokenId" | |
const ERR_NOTOKENID = "No tokenId specified" | |
const ERR_NOQTY = "No qty specified" | |
const ERR_NOTARGET = "No target specified" | |
const ERR_NOROYALTIES = "No royalties specified" | |
const ERR_NOFROM = "No sender specified" | |
const ERR_INVALID = "Invalid token transfer" | |
const ERR_INTEGER = "Invalid value. Must be an integer" | |
type Token = { | |
ticker: string, | |
owners?: string[], // Ordered list of NFTs' owners | |
balances: Record<string, number>, // owner -> balance | |
royalties?: Record<string, number>, // TODO: facultative? | |
} | |
type numberSettings = "primaryRate" | "secondaryRate" | "royaltyRate"; | |
type boolSettings = "allowFreeTransfer" | "paused" ; | |
type stringSettings = "communityChest"; | |
type stringsSettings = "contractOwners" | "contractSuperOwners" | "auctions"; | |
type Settings = { [key in numberSettings]: number} & | |
{ [key in boolSettings]: boolean} & | |
{ [key in stringSettings]: string} & | |
{ [key in stringsSettings]: string[]}; | |
type State = { | |
nonce: number, | |
settings: Settings, | |
tokens: Record<string, Token>, // token ID -> Token | |
operatorApprovals: Record<string, Record<string, boolean>> // owner -> approved operators... | |
invocations: string[], | |
}; | |
type Action = { | |
input: { | |
function: string, | |
settings: Settings, | |
from?: string, | |
froms?: string[], | |
target?: string, | |
targets?: string[], | |
royalties?: Record<string, number>, | |
owner?: string, | |
tokenId?: string, | |
tokenIds?: string[], | |
qty?: number, | |
qtys?: number[], | |
price?: number, | |
prices?: number[], | |
approved?: boolean, | |
no?: number, | |
nos?: number[] | |
invocationId?: string, | |
}, | |
caller: string, | |
}; | |
function tickerOf(state: State, tokenId: string): string { | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID) | |
const ticker = token.ticker | |
return ticker | |
} | |
function balanceOf(state: State, tokenId: string, target: string): number { | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID) | |
let qty = token.balances[target] | |
if (!qty) { | |
qty = 0 | |
} | |
return qty | |
} | |
function royaltiesOf(state: State, tokenId: string, target: string): number { | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID) | |
let qty = token.royalties ? token.royalties[target] : 0 | |
if (!qty) { | |
qty = 0 | |
} | |
return qty | |
} | |
function mintToken(state: State, target: string, tokenId: string, royalties?: Record<string, number>, qty?: number, no?:number) { | |
ContractAssert(!(tokenId in state.tokens), `tokenId already exists: "${tokenId}".`) | |
if (royalties) { | |
const sum = Object.values(royalties).reduce((acc, val) => acc + val, 0); | |
ContractAssert(sum === UNITY, `Sum of royalties shares must be ${UNITY}`) | |
} | |
let token = <Token>{ | |
ticker: `${PST}${state.nonce}`, | |
royalties: royalties, | |
owners: undefined, | |
balances: {}, | |
} | |
state.nonce++ | |
state.tokens[tokenId] = token | |
if (no) { // is an NFT | |
ContractAssert(Number.isInteger(no), ERR_INTEGER) | |
token.owners = Array(no).fill(target) | |
addTokenTo(state, target, tokenId, no) | |
} else if (qty) { // is a token | |
ContractAssert(!no, "no. must not be specified for tokens") | |
addTokenTo(state, target, tokenId, qty) | |
} | |
} | |
function addRoyaltiesTo(token: Token, target: string, qty: number) { | |
ContractAssert(token.royalties, ERR_NOROYALTIES); | |
if (!(target in token.royalties)) { | |
token.royalties[target] = 0 | |
} | |
token.royalties[target] = token.royalties[target] + qty | |
} | |
function removeRoyaltiesFrom(token: Token, from: string, qty: number) { | |
ContractAssert(token.royalties, ERR_NOROYALTIES); | |
const fromRoyalties = token.royalties[from] || 0 | |
ContractAssert(fromRoyalties > 0, "Sender does not own royalties on the token"); | |
ContractAssert(fromRoyalties >= qty, "Insufficient royalties' balance"); | |
const newBalance = token.royalties[from] - qty; | |
if (newBalance == 0) { | |
delete token.royalties[from] | |
} else { | |
token.royalties[from] = newBalance | |
} | |
} | |
function pay(state: State, token: Token, from: string, price: number) { | |
ContractAssert(token.royalties, ERR_NOROYALTIES); | |
ContractAssert(Number.isInteger(price), ERR_INTEGER) | |
ContractAssert(price >= 0, "Invalid value for price. Must be positive") | |
if (price == 0) { | |
return | |
} | |
if (from in token.royalties) { // primary sales | |
addTokenTo(state, state.settings["communityChest"], PST, price * state.settings["primaryRate"]) | |
for (const [target, split] of Object.entries(token.royalties)) { | |
addTokenTo(state, target, PST, price * (1 - state.settings["primaryRate"]) * split / UNITY) | |
} | |
} else { // secondary sales | |
addTokenTo(state, state.settings["communityChest"], PST, price * state.settings["secondaryRate"]) | |
for (const [target, split] of Object.entries(token.royalties)) { | |
addTokenTo(state, target, PST, price * state.settings["royaltyRate"] * split / UNITY) | |
} | |
} | |
} | |
function addTokenTo(state: State, target: string, tokenId: string, qty: number, no?:number) { | |
ContractAssert(Number.isInteger(qty), ERR_INTEGER) | |
ContractAssert(qty >= 0, "Invalid value for qty. Must be positive") | |
if (qty == 0) return | |
let token = state.tokens[tokenId] | |
ContractAssert(token, "tokenId does not exist") | |
if (!(target in token.balances)) { | |
token.balances[target] = 0 | |
} | |
token.balances[target] = token.balances[target] + qty | |
if (token.owners && no) { | |
ContractAssert(Number.isInteger(no), ERR_INTEGER) | |
ContractAssert(qty === 1, "Amount must be 1 for NFTs") | |
ContractAssert(token.owners[no-1] === "", "Token no. is already attributed") | |
token.owners[no-1] = target | |
} | |
} | |
function removeTokenFrom(state: State, from: string, tokenId: string, qty: number, no?:number) { | |
const fromBalance = balanceOf(state, tokenId, from) | |
ContractAssert(fromBalance > 0, "Sender does not own the token"); | |
ContractAssert(fromBalance >= qty, "Insufficient balance"); | |
let token = state.tokens[tokenId] | |
const newBalance = token.balances[from] - qty; | |
if (token.owners && no) { | |
ContractAssert(Number.isInteger(no), ERR_INTEGER) | |
ContractAssert(no > 0, "Invalid value for no. Must be positive") | |
ContractAssert(qty === 1, "Amount must be 1 for NFTs") | |
// ContractAssert(newBalance === 0, "NewBalance must be 0") | |
ContractAssert(token.owners[no-1] === from, "Token no. is not owned by caller") | |
token.owners[no-1] = "" | |
} | |
if (newBalance == 0) { | |
delete token.balances[from] | |
} else { | |
token.balances[from] = newBalance | |
} | |
} | |
function ownersOf(state: State, tokenId: string): string[] { | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID); | |
return Object.keys(token.balances) | |
} | |
// Is caller allowed to move owners tokens? | |
function isApprovedForAll(state: State, target: string, caller: string): boolean { | |
if (!(target in state.operatorApprovals)) | |
return false; | |
if (!(caller in state.operatorApprovals[target])) | |
return false; | |
return state.operatorApprovals[target][caller]; | |
} | |
function isApprovedOrOwner(state: State, caller: string, target: string, tokenId: string, no?: number): boolean { | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID); | |
ContractAssert(!token.owners || no, "no. must be set to look for NFT owner"); | |
if (token.owners && no && token.owners[no-1] === caller) { | |
return true | |
} else if (!token.owners && token.balances[caller]) { | |
return true | |
} | |
return isApprovedForAll(state, target, caller) | |
} | |
export async function handle(state: State, action: Action): Promise<{state?: State, result?:unknown}> { | |
const input = action.input | |
const caller = action.caller | |
const paused = state.settings["paused"] | |
const contractOwners = state.settings["contractOwners"] | |
const contractSuperOwners = state.settings["contractSuperOwners"] | |
// ContractAssert(input.function, "No function specified") | |
if (input.function === 'name') { | |
return { result : { name: "Pianity" } } | |
} | |
if (input.function === 'ticker') { | |
const tokenId = input.tokenId || PST; | |
const ticker = tickerOf(state, tokenId) | |
return { result: { ticker } } | |
} | |
if (input.function === 'balance') { | |
const target = input.target || caller; | |
const tokenId = input.tokenId || PST; | |
const balance = balanceOf(state, tokenId, target) | |
return { result: { target, balance } } | |
} | |
if (input.function === 'royalties') { | |
const target = input.target; | |
const tokenId = input.tokenId; | |
ContractAssert(tokenId, ERR_NOTOKENID) | |
ContractAssert(target, ERR_NOTARGET) | |
const royalties = royaltiesOf(state, tokenId, target) | |
return { result: { royalties } } | |
} | |
if (input.function === 'owner' || input.function == 'owners') { | |
const tokenId = input.tokenId | |
ContractAssert(tokenId, ERR_NOTOKENID) | |
const owners = ownersOf(state, tokenId) | |
return { result: { owners } } | |
} | |
if (input.function === 'isApprovedForAll') { | |
const target = input.target | |
const owner = input.owner | |
ContractAssert(owner, 'No owner specified'); | |
ContractAssert(target, ERR_NOTARGET); | |
const approved = isApprovedForAll(state, target, owner) | |
return { result: { approved } } | |
} | |
ContractAssert(!paused || contractSuperOwners.includes(caller), "The contract must not be paused") | |
if (input.function === 'setApprovalForAll') { | |
const approved = input.approved | |
const target = input.target | |
ContractAssert(target, ERR_NOTARGET); | |
ContractAssert(typeof approved !== 'undefined', 'No approved parameter specified'); | |
ContractAssert(target !== caller, 'Target must be different from the caller'); | |
if (!(caller in state.operatorApprovals)) { | |
state.operatorApprovals[caller] = {} | |
} | |
state.operatorApprovals[caller][target] = approved | |
return { state } | |
} | |
if (input.function === 'transferBatch') { | |
const targets = input.targets; | |
const froms = input.froms; | |
const tokenIds = input.tokenIds; | |
const qtys = input.qtys; | |
const prices = input.prices; | |
const nos = input.nos; | |
ContractAssert(froms, ERR_NOFROM) | |
ContractAssert(tokenIds, ERR_NOTOKENID) | |
ContractAssert(targets, ERR_NOTARGET) | |
ContractAssert(tokenIds.length === froms.length, "tokenIds and froms length mismatch") | |
ContractAssert(tokenIds.length === targets.length, "tokenIds and targets length mismatch") | |
for (var i in tokenIds) { | |
ContractAssert(froms[i] !== targets[i], ERR_INVALID) | |
let no = nos? nos[i] : undefined | |
let qty = qtys? qtys[i] : undefined | |
let price = prices? prices[i] : undefined | |
const token = state.tokens[tokenIds[i]] | |
ContractAssert(token, ERR_404TOKENID) | |
ContractAssert(!token.owners || (no && !qty), "no. must be set and qty unset for NFTs") | |
ContractAssert(token.owners || (!no && qty), "qty must be set and no unset for tokens") | |
ContractAssert(isApprovedOrOwner(state, caller, froms[i], tokenIds[i], no), `Sender is not the owner of the token (input ${i})`) | |
if (token.royalties) { | |
ContractAssert(state.settings["allowFreeTransfer"] || contractOwners.includes(caller), "Free transfers not allowed") | |
ContractAssert(!price || contractOwners.includes(caller), "Priced transfers must come from contract owner") | |
removeTokenFrom(state, targets[i], PST, price || 0) | |
pay(state, token, froms[i], price || 0) | |
} | |
removeTokenFrom(state, froms[i], tokenIds[i], qty || 1, no) | |
addTokenTo(state, targets[i], tokenIds[i], qty || 1, no) | |
} | |
return { state } | |
} | |
if (input.function === 'transfer') { | |
const target = input.target; | |
const from = input.from || caller; | |
const tokenId = input.tokenId || PST; | |
const qty = input.qty; | |
const price = input.price; | |
const no = input.no; | |
ContractAssert(target, ERR_NOTARGET) | |
ContractAssert(from !== target, ERR_INVALID) | |
const token = state.tokens[tokenId] | |
ContractAssert(token, ERR_404TOKENID) | |
ContractAssert(!token.owners || (no && !qty), "no. must be set and qty unset for NFTs") | |
ContractAssert(token.owners || (!no && qty), "qty must be set and no unset for tokens") | |
ContractAssert(isApprovedOrOwner(state, caller, from, tokenId, no), "Sender is not approved nor the owner of the token") | |
if (token.royalties) { | |
ContractAssert(state.settings["allowFreeTransfer"] || contractOwners.includes(caller), "Free transfers not allowed") | |
ContractAssert(!price || contractOwners.includes(caller), "Priced transfers must come from contract owner") | |
removeTokenFrom(state, target, PST, price || 0) | |
pay(state, token, from, price || 0) | |
} | |
removeTokenFrom(state, from, tokenId, qty || 1, no) | |
addTokenTo(state, target, tokenId, qty || 1, no) | |
return { state } | |
} | |
if (input.function === 'transferRoyalties') { | |
const target = input.target; | |
const tokenId = input.tokenId; | |
const qty = input.qty; | |
ContractAssert(target, ERR_NOTARGET) | |
ContractAssert(qty, ERR_NOQTY) | |
ContractAssert(caller !== target, ERR_INVALID) | |
ContractAssert(tokenId, ERR_NOTOKENID) | |
ContractAssert(qty > 0, "Invalid value for qty. Must be positive") | |
let token = state.tokens[tokenId]; | |
ContractAssert(token, "tokenId does not exist"); | |
ContractAssert(token.royalties, "Royalties are not set for this token") | |
removeRoyaltiesFrom(token, caller, qty); | |
addRoyaltiesTo(token, target, qty); | |
const sum = Object.values(token.royalties).reduce((acc, val) => acc + val, 0); | |
ContractAssert(sum === UNITY, `Sum of royalties shares must be ${UNITY}`) | |
return { state } | |
} | |
if (input.function === "foreignInvoke") { | |
const target = input.target | |
const invocationId = input.invocationId | |
ContractAssert(contractOwners.includes(caller), 'Caller is not authorized to foreignInvoke'); | |
ContractAssert(target, ERR_NOTARGET); | |
ContractAssert(typeof invocationId !== 'undefined', "No invocationId specified"); | |
ContractAssert(state.settings["auctions"], "No auctions specified"); | |
ContractAssert(state.settings["auctions"].includes(target), "Invalid auction contract"); | |
const foreignState = await SmartWeave.contracts.readContractState(target); | |
ContractAssert(foreignState.foreignCalls, "Contract is missing support for foreign calls"); | |
const invocation = foreignState.foreignCalls[invocationId]; | |
ContractAssert(invocation, `Incorrect invocationId: invocation not found (${invocationId})`) | |
ContractAssert(state.invocations.includes(invocation), "Contract invocation already exists") | |
const foreignAction = action; | |
foreignAction.input = invocation; | |
const resultState = await handle(state, foreignAction); | |
return { state: resultState.state }; | |
} | |
if (input.function === 'mint') { | |
const target = input.target | |
const tokenId = SmartWeave.transaction.id | |
const royalties = input.royalties | |
const qty = input.qty | |
const no = input.no | |
ContractAssert(contractOwners.includes(caller), 'Caller is not authorized to mint'); | |
ContractAssert(target, ERR_NOTARGET); | |
ContractAssert(tokenId, ERR_NOTOKENID); | |
ContractAssert((qty && !no) || (!qty && no), "qty and no can't be set simultaneously") | |
mintToken(state, target, tokenId, royalties, qty, no) | |
return { state } | |
} | |
if (input.function === 'settings') { | |
const settings = input.settings | |
const contractSuperOwners = state.settings["contractSuperOwners"] | |
const keys = Object.keys(settings) | |
ContractAssert(settings, "No settings specified") | |
if (!contractSuperOwners.includes(caller)) { | |
ContractAssert(contractOwners.includes(caller), 'Caller is not authorized to edit contract settings'); | |
ContractAssert(!keys.includes("paused") || settings["paused"] === true, "Caller is not Super Owner") | |
ContractAssert(!keys.includes("contractOwners"), "Caller is not Super Owner") | |
ContractAssert(!keys.includes("contractSuperOwners"), "Caller is not Super Owner") | |
} | |
Object.assign(state.settings, settings); | |
ContractAssert(state.settings["contractSuperOwners"].length > 0, "Can't delete all the Super Owners"); | |
return { state } | |
} | |
throw new (ContractError as any)(`No function supplied or function not recognised: "${input.function}".`); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment