From 63f678a1b35de2f15e0c30066219a1fb05752526 Mon Sep 17 00:00:00 2001 From: Kollan House Date: Tue, 9 Apr 2024 16:52:18 +0800 Subject: [PATCH 1/2] feat: adds common instructions, checks, verbose (#100) * feat: adds common instructions, checks, verbose * fix: adds space --- scripts/initializeProposal.ts | 157 +++++++++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 13 deletions(-) diff --git a/scripts/initializeProposal.ts b/scripts/initializeProposal.ts index cf3184fd..9ed53e35 100644 --- a/scripts/initializeProposal.ts +++ b/scripts/initializeProposal.ts @@ -2,6 +2,7 @@ import { initializeProposal, daoTreasury, META } from "./main"; import * as anchor from "@coral-xyz/anchor"; import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; import * as token from "@solana/spl-token"; +import { LAMPORTS_PER_SOL } from "@solana/web3.js"; const { PublicKey, Keypair, SystemProgram } = anchor.web3; const { BN, Program } = anchor; @@ -15,37 +16,167 @@ const PANTERA_PUBKEY = new PublicKey( "BtNPTBX1XkFCwazDJ6ZkK3hcUsomm1RPcfmtUrP6wd2K" ); -async function main() { - const senderAcc = await token.getOrCreateAssociatedTokenAccount( +const COST_DEPLOY = 4 * LAMPORTS_PER_SOL + +// Transfer +const buildTreasuryTransferInstruction = async ( + daoTreasury: anchor.web3.PublicKey, + destinationAccount: anchor.web3.PublicKey, + tokenMint: anchor.web3.PublicKey, + amount: number, +) => { + console.log(`Transfer token ${tokenMint.toString()}`); + // This gets the origin account with the token intended for transfer + const originAcc = await token.getOrCreateAssociatedTokenAccount( provider.connection, payer, - META, + tokenMint, daoTreasury, true ); + console.log('Origin account'); + console.log(originAcc.address.toString()); + console.log('Origin balance of token'); + const accountBalance = (await provider.connection.getTokenAccountBalance(originAcc.address)).value; + console.log(accountBalance.uiAmountString); + + const transferAmount = amount * (10 ** accountBalance.decimals); + + if (transferAmount > Number(accountBalance.amount)){ + console.error(`Account does not have enough balance to transfer ${transferAmount}`); + console.error(`Account's balance is ${accountBalance.amount}`); + return; + } + + console.log(`Transfer amount ${transferAmount / (10 ** accountBalance.decimals)}`); - const receiverAcc = await token.getOrCreateAssociatedTokenAccount( + // Sets up the destination account with the token and address provided + const destinationAcc = await token.getOrCreateAssociatedTokenAccount( provider.connection, payer, - META, - PANTERA_PUBKEY, + tokenMint, + destinationAccount, true ); + console.log('Destination account'); + console.log(destinationAcc.address.toString()); + const transferIx = token.createTransferInstruction( - senderAcc.address, - receiverAcc.address, + originAcc.address, + destinationAcc.address, + daoTreasury, + transferAmount + ); + + console.log('Transfer instructions'); + console.log(transferIx); + + return transferIx; +} + +// Memo +const buildMemoInstruction = async (memoText: string) => { + console.log('Memo text'); + console.log(memoText); + console.log('Memo text length'); + console.log(memoText.length); + const byteLengthOfMemo = Buffer.byteLength(memoText) + if (byteLengthOfMemo >= 566) { + throw Error('Memo text is too big') + } + console.log('Memo program PublicKey'); + console.log(MEMO_PROGRAM_ID); + + const createMemoIx = { + programId: new PublicKey(MEMO_PROGRAM_ID), + keys: [], + data: Buffer.from(memoText), + } + + console.log('Memo instructions'); + console.log(createMemoIx); + + return createMemoIx; +} + +// Burn +const buildTreasuryBurnInstruction = async (daoTreasury: anchor.web3.PublicKey, tokenMint: anchor.web3.PublicKey, amount: number) => { + console.log(`Burn token ${tokenMint.toString()}`); + // This gets the origin account with the token intended for burn + const originAcc = await token.getOrCreateAssociatedTokenAccount( + provider.connection, + payer, + tokenMint, daoTreasury, - 1_000 * 1_000_000_000 // 1,000 META + true + ); + console.log('Origin account'); + console.log(originAcc.address.toString()); + console.log('Origin balance of token'); + const accountBalance = (await provider.connection.getTokenAccountBalance(originAcc.address)).value; + console.log(accountBalance.uiAmountString); + + const burnAmount = amount * (10 ** accountBalance.decimals); + + if (burnAmount > Number(accountBalance.amount)){ + console.error(`Account does not have enough balance to burn ${burnAmount}`); + console.error(`Account's balance is ${accountBalance.amount}`); + return; + } + + console.log(`Burn amount ${burnAmount / (10 ** accountBalance.decimals)}`); + + const burnIx = token.createBurnInstruction( + originAcc.address, + tokenMint, + daoTreasury, + burnAmount ); + console.log('Burn instructions'); + console.log(burnIx); + + return burnIx; +} + +async function main() { + console.log('Initializing with PublicKey'); + console.log(payer.publicKey.toString()); + const payerBalance = await provider.connection.getBalance(payer.publicKey); + console.log('PublicKey SOL balance'); + console.log(payerBalance / LAMPORTS_PER_SOL); + + // Check to ensure the payer has enough SOL to actually execute the proposal creation + if (payerBalance < COST_DEPLOY) { + const diff = COST_DEPLOY - payerBalance; + console.error(`PublicKey doesn't have enough balance ${payerBalance / LAMPORTS_PER_SOL} to initialize proposal ${COST_DEPLOY / LAMPORTS_PER_SOL}`); + console.error(`Add ${diff / LAMPORTS_PER_SOL} more SOL`); + return + } + + console.log('Account has enough SOL (4) to continue'); + + // const proposalIx = await buildTreasuryBurnInstruction(daoTreasury, META, 1337); + + // const proposalIx = await buildTreasuryTransferInstruction(daoTreasury, PANTERA_PUBKEY, META, 342) + + const proposalIx = await buildMemoInstruction("TESTING DEVNET"); + const ix = { - programId: transferIx.programId, - accounts: transferIx.keys, - data: transferIx.data, + programId: proposalIx.programId, + accounts: proposalIx.keys, + data: proposalIx.data, }; - await initializeProposal(ix, "https://hackmd.io/@0xNallok/Hy2WJ46op"); + console.log('Proposal instruction'); + console.log(ix); + + // Sleep for review + console.log('Sleeping for 60s, press ctrl + c to cancel'); + await new Promise(f => setTimeout(f, 60000)); + + await initializeProposal(ix, "https://google.com"); } main(); From 72cb0e717646d4f71a9f71af9dd021c7dfddd1c4 Mon Sep 17 00:00:00 2001 From: Proph3t Date: Tue, 9 Apr 2024 09:07:55 +0000 Subject: [PATCH 2/2] fix: scripts for conditional metadata upload (#116) Co-authored-by: shio --- README.md | 14 +++ scripts/main.ts | 172 ++++++++++++++++++++---------- scripts/uploadOffchainMetadata.ts | 100 +++++++++++++---- 3 files changed, 205 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 273906f9..90420235 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,20 @@ SVM instruction that you want to use in your proposal. Then, run `anchor run propose --provider.cluster CLUSTER`, where `CLUSTER` is replaced with either devnet, mainnet, or (recommended) an RPC URL. +### Initialize Proposal + +The initialize proposal script initializes conditional vaults, which also attempts to upload metadata for conditional tokens. If a previous attempt to call this script failed part way through and off-chain metadata has already been uploaded, you can use this metadata and bypass another attempt to upload off-chain metadata. + +Simply prepend the script with the following environment variable structure: `[PASS|FAIL]_[TOKEN]_METADATA_URI`. For example, to override pass and fail META metadata uploads, include `PASS_META_METADATA_URI` and `FAIL_META_METADATA_URI`. + +The actual script invocation might look something like this: + +```bash +PASS_META_METADATA_URI=\"\" FAIL_META_METADATA_URI=\"\" anchor run propose +``` + +where `P_URI` and `F_URI` are replaced with their respective values. + ## Deployments | program | tag | program ID | diff --git a/scripts/main.ts b/scripts/main.ts index 3d5da4c4..46f4a722 100644 --- a/scripts/main.ts +++ b/scripts/main.ts @@ -1,6 +1,5 @@ import * as anchor from "@coral-xyz/anchor"; -// @ts-ignore -import * as token from "@solana/spl-token-018"; +import * as token from "@solana/spl-token"; const { BN, Program } = anchor; import { MPL_TOKEN_METADATA_PROGRAM_ID as UMI_MPL_TOKEN_METADATA_PROGRAM_ID } from "@metaplex-foundation/mpl-token-metadata"; @@ -19,6 +18,7 @@ import { SYSVAR_RENT_PUBKEY, SystemProgram, Transaction, + TransactionInstruction, } from "@solana/web3.js"; import { @@ -139,6 +139,80 @@ async function createMint( ); } +/** + * note: we will skip attempting to upload off-chain metadata for tokens + * - without associated metaplex metadata + * - a symbol that is not USDC or META + * + * this is done so that the script will not fail when using localnet or devnet + */ +async function generateAddMetadataToConditionalTokensIx( + mint: PublicKey, + onFinalizeMint: PublicKey, + onRevertMint: PublicKey, + vault: PublicKey, + nonce: anchor.BN +): Promise { + const tokenMetadata = await fetchOnchainMetadataForMint(mint); + if (!tokenMetadata) { + console.warn( + `no metadata found for token = ${mint.toBase58()}, conditional tokens will not have metadata` + ); + return undefined; + } + + const { metadata, key: metadataKey } = tokenMetadata; + const conditionalOnFinalizeTokenMetadataKey = await findMetaplexMetadataPda( + onFinalizeMint + ); + const conditionalOnRevertTokenMetadataKey = await findMetaplexMetadataPda( + onRevertMint + ); + + // pull off the least significant 32 bits representing the proposal count + const proposalCount = nonce.and(new BN(1).shln(32).sub(new BN(1))); + + // create new json, take that and pipe into the instruction + const uploadResult = await uploadOffchainMetadata( + proposalCount, + metadata.symbol + ); + + if (!uploadResult) return undefined; + const { passTokenMetadataUri, failTokenMetadataUri } = uploadResult; + if (!passTokenMetadataUri || !failTokenMetadataUri) { + // an error here is likely transient, so we want to fail the script so that the caller can try again. otherwise, we will end up with a token with no linkable off-chain metadata. + throw new Error( + `required metadata is undefined, pass = ${passTokenMetadataUri}, fail = ${failTokenMetadataUri}. Please try again.` + ); + } + + console.log( + `[proposal = ${proposalCount.toNumber()}] pass token metadata uri: ${passTokenMetadataUri}, fail token metadata uri: ${failTokenMetadataUri}` + ); + + return vaultProgram.methods + .addMetadataToConditionalTokens( + proposalCount, + passTokenMetadataUri, + failTokenMetadataUri + ) + .accounts({ + payer: payer.publicKey, + vault, + underlyingTokenMint: mint, + underlyingTokenMetadata: metadataKey, + conditionalOnFinalizeTokenMint: onFinalizeMint, + conditionalOnRevertTokenMint: onRevertMint, + conditionalOnFinalizeTokenMetadata: conditionalOnFinalizeTokenMetadataKey, + conditionalOnRevertTokenMetadata: conditionalOnRevertTokenMetadataKey, + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + }) + .instruction(); +} + async function initializeVault( settlementAuthority: any, underlyingTokenMint: any, @@ -169,50 +243,16 @@ async function initializeVault( let conditionalOnFinalizeKP = Keypair.generate(); let conditionalOnRevertKP = Keypair.generate(); - const { key: underlyingTokenMetadataKey, metadata: underlyingTokenMetadata } = - await fetchOnchainMetadataForMint(underlyingTokenMint); - - console.log( - `metadata for token = ${underlyingTokenMint.toBase58()}`, - underlyingTokenMetadata - ); - - const conditionalOnFinalizeTokenMetadata = await findMetaplexMetadataPda( - conditionalOnFinalizeKP.publicKey - ); - const conditionalOnRevertTokenMetadata = await findMetaplexMetadataPda( - conditionalOnRevertKP.publicKey - ); - - // pull off the least significant 32 bits representing the proposal count - const proposalCount = nonce.and(new BN(1).shln(32).sub(new BN(1))); - - // create new json, take that and pipe into the instruction - const { passTokenMetadataUri, faileTokenMetadataUri } = - await uploadOffchainMetadata(proposalCount, underlyingTokenMetadata.symbol); - - const addMetadataToConditionalTokensIx = await vaultProgram.methods - .addMetadataToConditionalTokens( - proposalCount, - passTokenMetadataUri, - faileTokenMetadataUri - ) - .accounts({ - payer: payer.publicKey, - vault, + const addMetadataToConditionalTokensIx = + await generateAddMetadataToConditionalTokensIx( underlyingTokenMint, - underlyingTokenMetadata: underlyingTokenMetadataKey, - conditionalOnFinalizeTokenMint: conditionalOnFinalizeKP.publicKey, - conditionalOnRevertTokenMint: conditionalOnRevertKP.publicKey, - conditionalOnFinalizeTokenMetadata, - conditionalOnRevertTokenMetadata, - tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, - systemProgram: SystemProgram.programId, - rent: SYSVAR_RENT_PUBKEY, - }) - .instruction(); + conditionalOnFinalizeKP.publicKey, + conditionalOnRevertKP.publicKey, + vault, + nonce + ); - await vaultProgram.methods + const initializeConditionalVaultBuilder = vaultProgram.methods .initializeConditionalVault(settlementAuthority, nonce) .accounts({ vault, @@ -228,14 +268,27 @@ async function initializeVault( .signers([conditionalOnFinalizeKP, conditionalOnRevertKP]) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ - units: 150_000 + units: 150_000, }), ComputeBudgetProgram.setComputeUnitPrice({ - microLamports: 100 + microLamports: 100, }), - ]) - .postInstructions([addMetadataToConditionalTokensIx]) - .rpc(); + ]); + + if (addMetadataToConditionalTokensIx) { + console.log( + "appending add metadata instruction for initialize vault transaction..." + ); + initializeConditionalVaultBuilder.postInstructions([ + addMetadataToConditionalTokensIx, + ]); + } else { + console.log( + "skipping add metadata instruction for initialize vault transaction..." + ); + } + + await initializeConditionalVaultBuilder.rpc(); //const storedVault = await vaultProgram.account.conditionalVault.fetch( // vault @@ -245,16 +298,17 @@ async function initializeVault( return vault; } -export async function initializeDAO(META: any, USDC: any) { - await autocratProgram.methods - .initializeDao() - .accounts({ - dao, - metaMint: META, - usdcMint: USDC, - }) - .rpc(); -} +// todo: need to fix after contract updates, otherwise we get a typescript compiler error +// export async function initializeDAO(META: any, USDC: any) { +// await autocratProgram.methods +// .initializeDao() +// .accounts({ +// dao, +// metaMint: META, +// usdcMint: USDC, +// }) +// .rpc(); +// } export async function fetchDao() { return autocratProgram.account.dao.fetch(dao); diff --git a/scripts/uploadOffchainMetadata.ts b/scripts/uploadOffchainMetadata.ts index f6fdb7cd..943b6de8 100755 --- a/scripts/uploadOffchainMetadata.ts +++ b/scripts/uploadOffchainMetadata.ts @@ -124,59 +124,115 @@ export const uploadImageJson = async ( const market = conditionalToken.toLowerCase().startsWith("p") ? "pass" : "fail"; - return umi.uploader.uploadJson({ - name: `Proposal ${proposal.toNumber()}: ${conditionalToken}`, - image, - symbol: conditionalToken, - description: `Native token in the MetaDAO's conditional ${market} market for proposal ${proposal.toNumber()}`, - }); + return umi.uploader.uploadJson( + { + name: `Proposal ${proposal.toNumber()}: ${conditionalToken}`, + image, + symbol: conditionalToken, + description: `Native token in the MetaDAO's conditional ${market} market for proposal ${proposal.toNumber()}`, + }, + { + onProgress: (percent: number, ...args: any) => { + console.log( + `percent metadata upload progress for ${conditionalToken} = ${percent}` + ); + console.log("progress args: ", args); + }, + } + ); }; export const uploadOffchainMetadata = async ( proposal: anchor.BN, symbol: string -) => { +): + | Promise<{ + symbol: string; + passTokenMetadataUri: string | undefined; + failTokenMetadataUri: string | undefined; + }> + | undefined => { + const isOverrideDefined = (value: string) => + value && value.trim().length > 0 && value.includes("https://"); + + const getValueOrUndefined = ( + result: PromiseSettledResult, + action?: string + ): T | undefined => { + if (result.status === "rejected") { + console.error( + `${action ? `[${action}] ` : "request failed: "}`, + result.reason + ); + return undefined; + } + + return result.value; + }; + // use bundlr, targeting arweave umi.use(bundlrUploader()); if (!isAcceptedVaultToken(symbol)) { - throw new Error(`Unrecognized symbol provided: ${symbol}`); + console.warn( + `unrecognized symbol provided. Skipping upload since we do not have conditional images for token: ${symbol}...` + ); + return undefined; } - console.log(`uploading metadata for token ${symbol}...`); - const [passUri, failUri] = await Promise.all( - [`p${symbol}`, `f${symbol}`].map((symbol) => { + console.log(`uploading metadata for conditional ${symbol} tokens...`); + const [passUploadResult, failUploadResult] = await Promise.allSettled( + [ + { + symbol: `p${symbol}`, + override: process.env[`PASS_${symbol}_METADATA_URI`], + }, + { + symbol: `f${symbol}`, + override: process.env[`FAIL_${symbol}_METADATA_URI`], + }, + ].map((o) => { + if (isOverrideDefined(o.override)) return o.override.trim(); return uploadImageJson( proposal, - symbol as ConditionalToken, - uploadedAssetMap[symbol] + o.symbol as ConditionalToken, + uploadedAssetMap[o.symbol] ); }) ); return { symbol, - passTokenMetadataUri: passUri, - faileTokenMetadataUri: failUri, + passTokenMetadataUri: getValueOrUndefined( + passUploadResult, + "upload pass token metadata" + ), + failTokenMetadataUri: getValueOrUndefined( + failUploadResult, + "upload fail token metadata" + ), }; }; +/** + * todo: when we support other metadata implementations (e.g. metaplex, token22), + * this method needs to check for those implementations + */ export const fetchOnchainMetadataForMint = async ( address: PublicKey -): - | Promise<{ +): Promise< + | { key: PublicKey; metadata: Metadata; - }> - | undefined => { + } + | undefined +> => { const pda = findMetadataPda(umi, { mint: fromWeb3JsPublicKey(address), }); const acct = await umi.rpc.getAccount(pda[0]); - if (!acct.exists) { - throw new Error(`Unable to find metaplex metdata for mint = ${address}`); - } + if (!acct.exists) return undefined; return { key: toWeb3JsPublicKey(pda[0]),