diff --git a/doc/getting-started/minting-nfts.md b/doc/getting-started/minting-nfts.md new file mode 100644 index 00000000..ff4dd6c8 --- /dev/null +++ b/doc/getting-started/minting-nfts.md @@ -0,0 +1,251 @@ + +# Minting Nfts using yoroi backend and cardano-serialization-lib + +This example mints nfts directly to an account on testnet. + +code taken from (here)[https://github.com/ozgrakkurt/cardano-mint-nft] + +```javascript +import CardanoWasm from "@emurgo/cardano-serialization-lib-nodejs"; +import axios from "axios"; +import cbor from "cbor"; + +const mintNft = async ( + privateKey, + policy, + assetName, + description, + imageUrl, + mediaType +) => { + const FEE = 300000; + + const publicKey = privateKey.to_public(); + + const addr = CardanoWasm.BaseAddress.new( + CardanoWasm.NetworkInfo.testnet().network_id(), + CardanoWasm.StakeCredential.from_keyhash(publicKey.hash()), + CardanoWasm.StakeCredential.from_keyhash(publicKey.hash()) + ).to_address(); + + const policyPubKey = policy.privateKey.to_public(); + + const policyAddr = CardanoWasm.BaseAddress.new( + CardanoWasm.NetworkInfo.testnet().network_id(), + CardanoWasm.StakeCredential.from_keyhash(policyPubKey.hash()), + CardanoWasm.StakeCredential.from_keyhash(policyPubKey.hash()) + ).to_address(); + + console.log(`ADDR: ${addr.to_bech32()}`); + + // get utxos for our address and select one that is probably big enough to pay the tx fee + const utxoRes = await axios.post( + "https://testnet-backend.yoroiwallet.com/api/txs/utxoForAddresses", + { + addresses: [addr.to_bech32()], + } + ); + + let utxo = null; + + if (utxoRes.data) { + for (const utxoEntry of utxoRes.data) { + if (utxoEntry.amount > FEE) { + utxo = utxoEntry; + } + } + } + + if (utxo === null) { + throw new Error("no utxo found with sufficient ADA."); + } + + console.log(`UTXO: ${JSON.stringify(utxo, null, 4)}`); + + // get current global slot from yoroi backend + const { data: slotData } = await axios.get( + "https://testnet-backend.yoroiwallet.com/api/v2/bestblock" + ); + + const ttl = slotData.globalSlot + 60 * 60 * 2; // two hours from now + + const txBuilder = CardanoWasm.TransactionBuilder.new( + CardanoWasm.TransactionBuilderConfigBuilder.new() + .fee_algo( + CardanoWasm.LinearFee.new( + CardanoWasm.BigNum.from_str("44"), + CardanoWasm.BigNum.from_str("155381") + ) + ) + .coins_per_utxo_word(CardanoWasm.BigNum.from_str("34482")) + .pool_deposit(CardanoWasm.BigNum.from_str("500000000")) + .key_deposit(CardanoWasm.BigNum.from_str("2000000")) + .max_value_size(5000) + .max_tx_size(16384) + .build() + ); + + const scripts = CardanoWasm.NativeScripts.new(); + + const policyKeyHash = CardanoWasm.BaseAddress.from_address(policyAddr) + .payment_cred() + .to_keyhash(); + + console.log( + `POLICY_KEYHASH: ${Buffer.from(policyKeyHash.to_bytes()).toString("hex")}` + ); + + // add key hash script so only people with policy key can mint assets using this policyId + const keyHashScript = CardanoWasm.NativeScript.new_script_pubkey( + CardanoWasm.ScriptPubkey.new(policyKeyHash) + ); + scripts.add(keyHashScript); + + const policyTtl = policy.ttl || ttl; + + console.log(`POLICY_TTL: ${policyTtl}`); + + // add timelock so policy is locked after this slot + const timelock = CardanoWasm.TimelockExpiry.new(policyTtl); + const timelockScript = CardanoWasm.NativeScript.new_timelock_expiry(timelock); + scripts.add(timelockScript); + + const mintScript = CardanoWasm.NativeScript.new_script_all( + CardanoWasm.ScriptAll.new(scripts) + ); + + const privKeyHash = CardanoWasm.BaseAddress.from_address(addr) + .payment_cred() + .to_keyhash(); + txBuilder.add_key_input( + privKeyHash, + CardanoWasm.TransactionInput.new( + CardanoWasm.TransactionHash.from_bytes(Buffer.from(utxo.tx_hash, "hex")), + utxo.tx_index + ), + CardanoWasm.Value.new(CardanoWasm.BigNum.from_str(utxo.amount)) + ); + + txBuilder.add_mint_asset_and_output_min_required_coin( + mintScript, + CardanoWasm.AssetName.new(Buffer.from(assetName)), + CardanoWasm.Int.new_i32(1), + CardanoWasm.TransactionOutputBuilder.new().with_address(addr).next() + ); + + const policyId = Buffer.from(mintScript.hash().to_bytes()).toString("hex"); + + console.log(`POLICY_ID: ${policyId}`); + + const metadata = { + [policyId]: { + [assetName]: { + name: assetName, + description, + image: imageUrl, + mediaType, + }, + }, + }; + + console.log(`METADATA: ${JSON.stringify(metadata, null, 4)}`); + + // transaction ttl can't be later than policy ttl + const txTtl = ttl > policyTtl ? policyTtl : ttl; + + console.log(`TX_TTL: ${txTtl}`); + + txBuilder.set_ttl(txTtl); + txBuilder.add_json_metadatum( + CardanoWasm.BigNum.from_str("721"), + JSON.stringify(metadata) + ); + + txBuilder.add_change_if_needed(addr); + + const txBody = txBuilder.build(); + const txHash = CardanoWasm.hash_transaction(txBody); + + console.log(`TX_HASH: ${Buffer.from(txHash.to_bytes()).toString("hex")}`); + + // sign the tx using the policy key and main key + const witnesses = CardanoWasm.TransactionWitnessSet.new(); + const vkeyWitnesses = CardanoWasm.Vkeywitnesses.new(); + vkeyWitnesses.add(CardanoWasm.make_vkey_witness(txHash, policy.privateKey)); + vkeyWitnesses.add(CardanoWasm.make_vkey_witness(txHash, privateKey)); + witnesses.set_vkeys(vkeyWitnesses); + witnesses.set_native_scripts; + const witnessScripts = CardanoWasm.NativeScripts.new(); + witnessScripts.add(mintScript); + witnesses.set_native_scripts(witnessScripts); + + const unsignedTx = txBuilder.build_tx(); + + // create signed transaction + const tx = CardanoWasm.Transaction.new( + unsignedTx.body(), + witnesses, + unsignedTx.auxiliary_data() + ); + + const signedTx = Buffer.from(tx.to_bytes()).toString("base64"); + + // submit the transaction using yoroi backend + try { + const { data } = await axios.post( + "https://testnet-backend.yoroiwallet.com/api/txs/signed", + { + signedTx, + } + ); + + console.log(`SUBMIT_RESULT: ${JSON.stringify(data, null, 4)}`); + } catch (error) { + console.error( + `failed to submit tx via yoroi backend: ${error.toString()}. error details: ${JSON.stringify( + error.response?.data + )}` + ); + } +}; + +try { + const privateKey = CardanoWasm.PrivateKey.from_bech32( + //"ed25519_sk1fde2u8u2qme8uau5ac3w6c082gvtnmxt6uke2w8e07xwzewxee3q3n0f8e" + "ed25519_sk18j0a6704zyerm6dsj6p2fp8juw5m43rfgk0y84jnm7w5khs4dpqquewh43" + ); + + console.log(`PRIVATE KEY: ${privateKey.to_bech32()}`); + + /* + const policyPrivateKey = CardanoWasm.PrivateKey.from_bech32( + "ed25519_sk1q96x2g66j5g7u5wydl7kcagk0h8upxznt3gj48h6njqthkyr7faqxmnnte" + ); + */ + + // import policy key from a .skey file + const policyPrivateKey = CardanoWasm.PrivateKey.from_normal_bytes( + cbor.decodeFirstSync( + "582009ca7f508dd5a5f9823d367e98170f25606799f49ae7363a47a11d7d3502c91f" + ) + ); + + console.log(`POLICY_PRIV_KEY: ${policyPrivateKey.to_bech32()}`); + + await mintNft( + privateKey, // main key + { + privateKey: policyPrivateKey, // policy key + // pass null here to get automatic ttl for policy + // and paste the POLICY_TTL output you get in console to here to mint with same policy + ttl: null, // policy ttl + }, + "asdNFT5", // assetName + "some descr this is a new nft with same policy", // description + "ipfs://QmNhmDPJMgdsFRM9HyiQEJqrKkpsWFshqES8mPaiFRq9Zk", // image url + "image/jpeg" // mediaType + ); +} catch (err) { + console.error(`failed to mint nft: ${err.toString()}`); +} +``` \ No newline at end of file diff --git a/rust/src/utils.rs b/rust/src/utils.rs index b805a7f9..7aa57a3b 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -498,6 +498,8 @@ impl Deserialize for Value { #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct Int(pub (crate) i128); +to_from_bytes!(Int); + #[wasm_bindgen] impl Int { pub fn new(x: &BigNum) -> Self { @@ -592,13 +594,30 @@ impl Deserialize for Int { (|| -> Result<_, DeserializeError> { match raw.cbor_type()? { cbor_event::Type::UnsignedInteger => Ok(Self(raw.unsigned_integer()? as i128)), - cbor_event::Type::NegativeInteger => Ok(Self(raw.negative_integer()? as i128)), + cbor_event::Type::NegativeInteger => Ok(Self(read_nint(raw)?)), _ => Err(DeserializeFailure::NoVariantMatched.into()), } })().map_err(|e| e.annotate("Int")) } } +/// TODO: this function can be removed in case `cbor_event` library ever gets a fix on their side +/// See https://github.com/Emurgo/cardano-serialization-lib/pull/392 +fn read_nint(raw: &mut Deserializer) -> Result { + let found = raw.cbor_type()?; + if found != cbor_event::Type::NegativeInteger { + return Err(cbor_event::Error::Expected(cbor_event::Type::NegativeInteger, found).into()); + } + let (len, len_sz) = raw.cbor_len()?; + match len { + cbor_event::Len::Indefinite => Err(cbor_event::Error::IndefiniteLenNotSupported(cbor_event::Type::NegativeInteger).into()), + cbor_event::Len::Len(v) => { + raw.advance(1 + len_sz)?; + Ok(-(v as i128) - 1) + } + } +} + const BOUNDED_BYTES_CHUNK_SIZE: usize = 64; pub (crate) fn write_bounded_bytes<'se, W: Write>(serializer: &'se mut Serializer, bytes: &[u8]) -> cbor_event::Result<&'se mut Serializer> { @@ -725,22 +744,28 @@ impl cbor_event::se::Serialize for BigInt { num_bigint::Sign::Minus => serializer.write_negative_integer(-(*u64_digits.first().unwrap() as i128) as i64), }, _ => { + // Small edge case: nint's minimum is -18446744073709551616 but in this bigint lib + // that takes 2 u64 bytes so we put that as a special case here: + if sign == num_bigint::Sign::Minus && u64_digits == vec![0, 1] { + serializer.write_negative_integer(-18446744073709551616i128 as i64) + } else { let (sign, bytes) = self.0.to_bytes_be(); - match sign { - // positive bigint - num_bigint::Sign::Plus | - num_bigint::Sign::NoSign => { - serializer.write_tag(2u64)?; - write_bounded_bytes(serializer, &bytes) - }, - // negative bigint - num_bigint::Sign::Minus => { - serializer.write_tag(3u64)?; - use std::ops::Neg; - // CBOR RFC defines this as the bytes of -n -1 - let adjusted = self.0.clone().neg().checked_sub(&num_bigint::BigInt::from(1u32)).unwrap().to_biguint().unwrap(); - write_bounded_bytes(serializer, &adjusted.to_bytes_be()) - }, + match sign { + // positive bigint + num_bigint::Sign::Plus | + num_bigint::Sign::NoSign => { + serializer.write_tag(2u64)?; + write_bounded_bytes(serializer, &bytes) + }, + // negative bigint + num_bigint::Sign::Minus => { + serializer.write_tag(3u64)?; + use std::ops::Neg; + // CBOR RFC defines this as the bytes of -n -1 + let adjusted = self.0.clone().neg().checked_sub(&num_bigint::BigInt::from(1u32)).unwrap().to_biguint().unwrap(); + write_bounded_bytes(serializer, &adjusted.to_bytes_be()) + }, + } } }, } @@ -772,7 +797,7 @@ impl Deserialize for BigInt { // uint CBORType::UnsignedInteger => Ok(Self(num_bigint::BigInt::from(raw.unsigned_integer()?))), // nint - CBORType::NegativeInteger => Ok(Self(num_bigint::BigInt::from(raw.negative_integer()?))), + CBORType::NegativeInteger => Ok(Self(num_bigint::BigInt::from(read_nint(raw)?))), _ => return Err(DeserializeFailure::NoVariantMatched.into()), } })().map_err(|e| e.annotate("BigInt")) @@ -2104,9 +2129,8 @@ mod tests { assert_eq!(hex::decode("c249010000000000000000").unwrap(), BigInt::from_str("18446744073709551616").unwrap().to_bytes()); // uint assert_eq!(hex::decode("1b000000e8d4a51000").unwrap(), BigInt::from_str("1000000000000").unwrap().to_bytes()); - // nint - // we can't use this due to cbor_event actually not supporting the full NINT spectrum as it uses an i64 for some reason... - //assert_eq!(hex::decode("3bffffffffffffffff").unwrap(), BigInt::from_str("-18446744073709551616").unwrap().to_bytes()); + // nint (lowest possible - used to be unsupported but works now) + assert_eq!(hex::decode("3bffffffffffffffff").unwrap(), BigInt::from_str("-18446744073709551616").unwrap().to_bytes()); // this one fits in an i64 though assert_eq!(hex::decode("3903e7").unwrap(), BigInt::from_str("-1000").unwrap().to_bytes()); @@ -2318,4 +2342,22 @@ mod tests { assert_eq!(Int::new_i32(42).as_i32_or_fail().unwrap(), 42); assert_eq!(Int::new_i32(-42).as_i32_or_fail().unwrap(), -42); } + + #[test] + fn int_full_range() { + // cbor_event's nint API worked via i64 but we now have a workaround for it + // so these tests are here to make sure that workaround works. + + // first nint below of i64::MIN + let bytes_x = vec![0x3b, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let x = Int::from_bytes(bytes_x.clone()).unwrap(); + assert_eq!(x.to_str(), "-9223372036854775809"); + assert_eq!(bytes_x, x.to_bytes()); + + // smallest possible nint which is -u64::MAX - 1 + let bytes_y = vec![0x3b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; + let y = Int::from_bytes(bytes_y.clone()).unwrap(); + assert_eq!(y.to_str(), "-18446744073709551616"); + assert_eq!(bytes_y, y.to_bytes()); + } }