diff --git a/.env.example b/.env.example index efa3ed1..7391687 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ PROTOCOL_IDENTIFIER=D -NODE_RPC_URL= -NODE_RPC_USER= -NODE_RPC_PASS= +NODE_RPC_URL=http://127.0.0.1:22555 +NODE_RPC_USER=rpc_user +NODE_RPC_PASS=rpc_password TESTNET=false -FEE_PER_KB=500000000 -UNSPENT_API=https://unspent.dogeord.io/api/v1/address/unspent/ -ORD=https://wonky-ord.dogeord.io/ +FEE_PER_KB=50000000 +UNSPENT_API=https://unspent.dogeord.io/api/v1/address/unspent/ //optional +ORD=https://wonky-ord-v2.dogeord.io/ +WALLET=.wallet.json +SERVER_PORT=3000 diff --git a/.wallet.json b/.wallet.json new file mode 100644 index 0000000..dc8a605 --- /dev/null +++ b/.wallet.json @@ -0,0 +1,5 @@ +{ + "privkey": "your doge private key goes here", + "address": "your doge address goes here", + "utxos": [] +} diff --git a/DUNES.md b/DUNES.md index d4eca5d..5c2deb1 100644 --- a/DUNES.md +++ b/DUNES.md @@ -113,3 +113,17 @@ Malformed dunestones are termed cenotaphs. Dunes input to a transaction with a cenotaph are burned. Dunes etched in a transaction with a cenotaph are set as unmintable. Mints in a transaction with a cenotaph count towards the mint cap, but the minted dunes are burned. Cenotaphs are an upgrade mechanism, allowing dunestones to be given new semantics that change how dunes are created and transferred, while not misleading unupgraded clients as to the location of those dunes, as unupgraded clients will see those dunes as having been burned. + +### Parent Inscriptions +Dunes can be linked to a "parent" inscription, establishing a hierarchical relationship. This allows Dunes to be associated with other digital assets, such as images or other Dunes. + +**How It Works**: +- During etching, you can specify a `parentId` (the inscription ID of the parent, e.g., `e6c6efe91b6084eae2c8a2fd6470d3d0dbfbb342f1b8601904f45be8095058e2i0`). +- The etching transaction spends the parent inscription’s UTXO, embedding the link in the blockchain. +- The etcher must control the parent inscription (i.e., have its private key). + +**Use Cases**: +- Associate multiple Dunes with a single parent (e.g., an image inscription). +- Create structured collections of digital assets with parent-child relationships. + +**Note**: This feature is optional. Dunes can be etched without a parent if no `parentId` is provided. diff --git a/README.md b/README.md index f6560cb..3ec5509 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ git clone https://github.com/sirduney/dunes-cli.git download this [zip file](https://github.com/verydogelabs/do20nals/archive/refs/heads/main.zip) and upack in a directory. + Now open your terminal and change to the directory the sources are installed. ``` -cd -npm install +cd +npm install audit fix ``` After all dependencies are solved, you can configure the environment: @@ -57,8 +58,8 @@ NODE_RPC_URL=http://: NODE_RPC_USER= NODE_RPC_PASS= TESTNET=false -FEE_PER_KB=500000000 -ORD=https://ord.dunesprotocol.com/ +FEE_PER_KB=50000000 +ORD=https://wonky-ord-v2.dogeord.io/ ``` You can get the current fee per kb from [here](https://blockchair.com/). @@ -94,17 +95,19 @@ node dunes.js wallet send
Deploy a dune: ``` -node dunes.js deployOpenDune 'RANDOM•DUNE•NAME' +node dunes.js deployOpenDune 'RANDOM•DUNE•NAME' [parentId] ``` -Example for a dune that can be minted for 100 blocks, with a limit of 100000000, 8 decimals, no mint limit, symbol R (emojis also work) and premining 1R for the deployer during deployment. The `false` means the dune will not opt-in for future protocol changes. The `true` means mints are open. +**Note**: The `[parentId]` parameter is optional and links the new Dune to a parent inscription by specifying its inscription ID (e.g., `e6c6efe91b6084eae2c8a2fd6470d3d0dbfbb342f1b8601904f45be8095058e2i0`). To use this, the parent inscription’s private key must be in your wallet (`.wallet.json`). -The `null` means that the parameters won't be included, e.g. for not setting a max nr of mints limit. If absolute and relative block heights are defined, the dune minting terms will use the highest defined for start as the starting block height parameter and then lowest defined end as the ending block height. +**Example**: ``` -node dunes.js deployOpenDune 'RANDOM•DUNE•NAME' 'R' 100000000 8 null null null null 100 100000000 false true +node dunes.js deployOpenDune 'RANDOM•DUNE•NAME' 'R' 100000000 8 null null null null 100 100000000 false true e6c6efe91b6084eae2c8a2fd6470d3d0dbfbb342f1b8601904f45be8095058e2i0 ``` +This deploys a Dune named `RANDOM•DUNE•NAME` with symbol `R`, a limit of 100000000 per mint, 8 decimals, and premines 1R (100 atomic units) to the deployer. It’s linked to the parent inscription `e6c6...i0` + Mint a dune: ``` @@ -129,7 +132,7 @@ Example (this will do 100x mints): node dunes.js batchMintDune '5088000:50' 100000000 100 DTZSTXecLmSXpRGSfht4tAMyqra1wsL7xb ``` -Get the ID from: https://ord.dunesprotocol.com/dunes +Get the ID here from: [Wonky Dogeord](https://wonky-ord-v2.dogeord.io/dunes) Print the balance of an address: @@ -167,6 +170,100 @@ Example: node dunes.js sendDunesNoProtocol DDjbkNTHPZAq3f6pzApDzP712V1xqSE2Ya 10 WHO•LET•THE•DUNES•OUT ``` +### Parent Inscriptions +You can link a new Dune to an existing "parent" inscription (e.g., an image or another Dune) using the optional `[parentId]` parameter in `deployOpenDune`. This creates a hierarchical relationship between digital assets. + +**Requirements**: +- The parent inscription’s private key must be in your wallet (`.wallet.json`), as the script spends its UTXO to establish the link. +- The `parentId` must be a valid inscription ID in the format `txid:iN` (e.g., `e6c6efe91b6084eae2c8a2fd6470d3d0dbfbb342f1b8601904f45be8095058e2i0`), where `txid` is the transaction ID and `N` is the output index. + +**Why Use It?**: +- Organize related Dunes under a single parent (e.g., linking single or multiple Dunes to an image). +- Build structured collections with parent-child relationships. + +**Important Notes**: +- This feature is optional. Dunes can be etched without a parent if no `parentId` is provided. + +- This "Requires" the parent’s private key to be accessible in the wallet. + +- Ensure that the parent inscription’s private key is accessible in the wallet derived from your MetaMask seed phrase. This is necessary because the transaction will spend the parent UTXO to establish the parent-child relationship +## Pay Terms (New) + + +### Apply Pay Terms + +***You can now **charge a mint price** on your Dune etchings:*** + +- **priceAmount** + A u128 string of shibes (1 DOGE = 100 000 000 shibes) to charge per mint, or `null` to disable fees. + +- **pricePayTo** + A Dogecoin address to receive collected fees, or `null`. + +When **both** `priceAmount` and `pricePayTo` are set in the `deployOpenDune` command, your Dune will carry on‑chain pay terms. Minters must pay that fee, and proceeds go straight to the specified address. + +**Example with Pay Terms**: + +``` +node dunes.js deployOpenDune \ + 'RANDOM•DUNE•NAME' \ + 'R' \ + 100000000 \ + 8 \ + null null null null \ + 100 \ + 100000000 \ + false \ + true \ + e6c6efe91b6084eae2c8a2fd6470d3d0dbfbb342f1b8601904f45be8095058e2i0 \ + 5000000 \ + DDogeFeeRecipientAddress +``` + +***Important Notes:*** +"apply pay terms” is the Key: it lets you set a mint price (in “shibes”) and specify who gets paid when someone mints a Dune. Previously, we could only open‐mint with limits, caps, and block‑height restrictions, etc.; now we can also monetize it by charging a fee and directing that fee to an address of your choice. +You also refactored the internal broadcast() function’s retryAsync call so it passes the helper directly instead of wrapping it in an extra async lambda: + +res = await retryAsync(async () => await makePostRequest(), 10, 30000); +res = await retryAsync(makePostRequest, 10, 30000); +This doesn’t change behavior, it does makes the retry logic a bit cleaner! + +Why im Digging this upgrade??? +Monetization: Projects can now sustainably fund themselves by charging a shibe‑denominated mint fee. + +**Flexibility:** + + Since the fee is just another field in the Terms object, it’s fully optional, so if you don’t want a fee, you pass "null" and everything behaves exactly like before. +Such Flexibility!!! + +**Backward‑compatible:** + +Existing scripts and deployments that never set a price will continue to work unchanged. + +**Decentralization & Dogecoin ethics:** + +***On‑chain enforcement only:*** + + All the fee logic happens in your local CLI client and then on‑chain via the same RPC calls and smart‑contract‑style logic Dunes has always used. There’s no off‑chain "Gatekeeper" or "Centralized service" validating payments. + +**User choice:** + + Anyone can deploy an open mint with or without a fee; you’re not forced to pay if you don’t want to. + +**Transparent rules:** + +All parameters (limits, heights, offsets, fees, pay‑to address) are encoded in the same public transaction data we’ve always put on Dogecoin. + +**No new trust assumptions:** + +You still only need to trust the Dogecoin network itself and your own keypair....there’s no additional trusted infrastructure introduced. + +*In short, it’s Such a clean, opt‑in extension that preserves the fully decentralized ethos of Dogecoin.* + +*Much Innovation, Such Decentralized, So Dunes +Well done Fam! Thanks for the Great Idea* @reallyshadydev https://x.com/reallyshadydev + + ## FAQ ### I'm getting ECONNREFUSED errors when minting @@ -177,6 +274,8 @@ There's a problem with the node connection. Your `dogecoin.conf` file should loo rpcuser=ape rpcpassword=zord rpcport=22555 +txindex=1 +rpccallip=127.0.0.1 server=1 ``` @@ -185,12 +284,51 @@ Make sure `port` is not set to the same number as `rpcport`. Also make sure `rpc Your `.env file` should look like: ``` +PROTOCOL_IDENTIFIER=D NODE_RPC_URL=http://127.0.0.1:22555 NODE_RPC_USER=ape NODE_RPC_PASS=zord TESTNET=false +FEE_PER_KB=50000000 +UNSPENT_API=https://unspent.dogeord.io/api/v1/address/unspent/ //optional +ORD=https://wonky-ord-v2.dogeord.io/ +WALLET=.wallet.json +SERVER_PORT=3000 ``` ### I'm getting "insufficient priority" errors when minting -The miner fee is too low. You can increase it up by putting FEE_PER_KB=300000000 in your .env file or just wait it out. The default is 100000000 but spikes up when demand is high. +***The miner fee is too low. You can increase it up by putting FEE_PER_KB=300000000 in your .env file or just wait it out. The default is 100000000 but spikes up when demand is high.*** + + +# Contributing + +If you'd like to contribute or donate to our projects, please donate in Dogecoin. For active contributors its as easy as opening issues, and creating pull requests + +Feel free to support with Donations, Send all Dogecoin to the following Contributors: + +***You can donate to*** **Duney** ***here:*** + +"handle": ***"SirDuney"*** "at": [***"@SirDuney"***](https://x.com/sirduney)) + +**"Đogecoin_address":** + + +***You can donate to*** **GreatApe** ***here:*** + +"handle": ***"GreatApe42069"*** "at": [***"@Greatape42069E"***](https://x.com/Greatape42069E) + + **"Đogecoin_address":** ***"D9pqzxiiUke5eodEzMmxZAxpFcbvwuM4Hg"*** + + +***You can donate to Apezord here:*** + +"handle": ***"Apezord"*** "at": [***"@apezord"***](https://x.com/apezord) + +**"Đogecoin_address":** ***"DNmrp12LfsVwy2Q2B5bvpQ1HU7zCAczYob"*** + + + +39 + + diff --git a/dunes.js b/dunes.js index e747bf1..80814d4 100755 --- a/dunes.js +++ b/dunes.js @@ -11,70 +11,63 @@ const { program } = require("commander"); const bb26 = require("base26"); const prompts = require("prompts"); -const axiosRetryOptions = { +dotenv.config(); + +const ordApi = axios.create({ baseURL: process.env.ORD, timeout: 100_000 }); +axiosRetry(axios, { retries: 10, retryDelay: axiosRetry.exponentialDelay, -}; - -axiosRetry(axios, axiosRetryOptions); - -dotenv.config(); +}); +// add this line to enable retries on ordApi: +axiosRetry(ordApi, { + retries: 10, + retryDelay: axiosRetry.exponentialDelay, +}); -if (process.env.TESTNET == "true") { +if (process.env.TESTNET === "true") { dogecore.Networks.defaultNetwork = dogecore.Networks.testnet; } if (process.env.FEE_PER_KB) { - Transaction.FEE_PER_KB = parseInt(process.env.FEE_PER_KB); + Transaction.FEE_PER_KB = parseInt(process.env.FEE_PER_KB, 10); } else { - Transaction.FEE_PER_KB = 100000000; + Transaction.FEE_PER_KB = 100_000_000; } const WALLET_PATH = process.env.WALLET || ".wallet.json"; - const IDENTIFIER = stringToCharCodes(process.env.PROTOCOL_IDENTIFIER); - const MAX_SCRIPT_ELEMENT_SIZE = 520; class PushBytes { constructor(bytes) { this.bytes = Buffer.from(bytes); } - static fromSliceUnchecked(bytes) { return new PushBytes(bytes); } - static fromMutSliceUnchecked(bytes) { return new PushBytes(bytes); } - static empty() { return new PushBytes([]); } - asBytes() { return this.bytes; } - asMutBytes() { return this.bytes; } } -// Encode a u128 value to a byte array function varIntEncode(n) { const out = new Array(19).fill(0); let i = 18; - out[i] = Number(BigInt(n) & 0b01111111n); - while (BigInt(n) > 0b01111111n) { n = BigInt(n) / 128n - 1n; i -= 1; out[i] = Number(BigInt(n) | 0b10000000n); } - return out.slice(i); } @@ -91,9 +84,7 @@ class Tag { static HeightEnd = 18; static Cap = 20; static Premine = 22; - static Cenotaph = 254; - static Divisibility = 1; static Spacers = 3; static Symbol = 5; @@ -102,10 +93,9 @@ class Tag { static take(tag, fields) { return fields[tag]; } - static encode(tag, value, payload) { payload.push(varIntEncode(tag)); - if (tag == Tag.Dune) payload.push(encodeToTuple(value)); + if (tag === Tag.Dune) payload.push(encodeToTuple(value)); else payload.push(varIntEncode(value)); } } @@ -119,20 +109,17 @@ class Flag { static mask(flag) { return BigInt(1) << BigInt(flag); } - static take(flag, flags) { - const mask = Flag.mask(flag); - const set = (flags & mask) !== 0n; - flags &= ~mask; + const m = Flag.mask(flag); + const set = (flags & m) !== 0n; + flags &= ~m; return set; } - static set(flag, flags) { - flags |= Flag.mask(flag); + return flags | Flag.mask(flag); } } -// Construct the OP_RETURN dune script with encoding of given values function constructScript( etching = null, pointer = undefined, @@ -142,7 +129,6 @@ function constructScript( const payload = []; if (etching) { - // Setting flags for etching and minting let flags = Number(Flag.mask(Flag.Etch)); if (etching.turbo) flags |= Number(Flag.mask(Flag.Turbo)); if (etching.terms) flags |= Number(Flag.mask(Flag.Terms)); @@ -150,22 +136,15 @@ function constructScript( if (etching.dune) Tag.encode(Tag.Dune, etching.dune, payload); if (etching.terms) { - if (etching.terms.limit) - Tag.encode(Tag.Limit, etching.terms.limit, payload); + if (etching.terms.limit) Tag.encode(Tag.Limit, etching.terms.limit, payload); if (etching.terms.cap) Tag.encode(Tag.Cap, etching.terms.cap, payload); - if (etching.terms.offsetStart) - Tag.encode(Tag.OffsetStart, etching.terms.offsetStart, payload); - if (etching.terms.offsetEnd) - Tag.encode(Tag.OffsetEnd, etching.terms.offsetEnd, payload); - if (etching.terms.heightStart) - Tag.encode(Tag.HeightStart, etching.terms.heightStart, payload); - if (etching.terms.heightEnd) - Tag.encode(Tag.HeightEnd, etching.terms.heightEnd, payload); + if (etching.terms.offsetStart) Tag.encode(Tag.OffsetStart, etching.terms.offsetStart, payload); + if (etching.terms.offsetEnd) Tag.encode(Tag.OffsetEnd, etching.terms.offsetEnd, payload); + if (etching.terms.heightStart) Tag.encode(Tag.HeightStart, etching.terms.heightStart, payload); + if (etching.terms.heightEnd) Tag.encode(Tag.HeightEnd, etching.terms.heightEnd, payload); } - if (etching.divisibility !== 0) - Tag.encode(Tag.Divisibility, etching.divisibility, payload); - if (etching.spacers !== 0) - Tag.encode(Tag.Spacers, etching.spacers, payload); + if (etching.divisibility !== 0) Tag.encode(Tag.Divisibility, etching.divisibility, payload); + if (etching.spacers !== 0) Tag.encode(Tag.Spacers, etching.spacers, payload); if (etching.symbol) Tag.encode(Tag.Symbol, etching.symbol, payload); if (etching.premine) Tag.encode(Tag.Premine, etching.premine, payload); } @@ -184,32 +163,23 @@ function constructScript( const sortedEdicts = edicts.slice().sort((a, b) => { const idA = BigInt(a.id); const idB = BigInt(b.id); - return idA < idB ? -1 : idA > idB ? 1 : 0; }); - let id = 0; + let lastId = 0n; for (const edict of sortedEdicts) { - if (typeof edict.id === "bigint") - payload.push(varIntEncode(edict.id - BigInt(id))); - else payload.push(varIntEncode(edict.id - id)); + payload.push(varIntEncode(edict.id - lastId)); payload.push(varIntEncode(edict.amount)); payload.push(varIntEncode(edict.output)); - id = edict.id; + lastId = edict.id; } } - // Create script with protocol message - let script = createScriptWithProtocolMsg(); - - // Flatten the nested arrays in the tuple representation - const flattenedTuple = payload.flat(); - - // Push payload bytes to script - for (let i = 0; i < flattenedTuple.length; i += MAX_SCRIPT_ELEMENT_SIZE) { - const chunk = flattenedTuple.slice(i, i + MAX_SCRIPT_ELEMENT_SIZE); - const push = PushBytes.fromSliceUnchecked(chunk); - script.add(Buffer.from(push.asBytes())); + const script = new Script().add("OP_RETURN").add(Buffer.from(IDENTIFIER)); + const flattened = payload.flat(); + for (let i = 0; i < flattened.length; i += MAX_SCRIPT_ELEMENT_SIZE) { + const chunk = flattened.slice(i, i + MAX_SCRIPT_ELEMENT_SIZE); + script.add(Buffer.from(PushBytes.fromSliceUnchecked(chunk).asBytes())); } return script; @@ -229,58 +199,33 @@ class Dune { } function parseDuneFromString(s) { - let x = BigInt(0); - + let x = 0n; for (let i = 0; i < s.length; i++) { - if (i > 0) { - x += BigInt(1); - } - - x *= BigInt(26); - - const charCode = s.charCodeAt(i); - - if (charCode >= "A".charCodeAt(0) && charCode <= "Z".charCodeAt(0)) { - x += BigInt(charCode - "A".charCodeAt(0)); - } else { - throw new Error(`Invalid character in dune name: ${s[i]}`); - } + if (i > 0) x += 1n; + x *= 26n; + const c = s.charCodeAt(i); + if (c >= 65 && c <= 90) x += BigInt(c - 65); + else throw new Error(`Invalid character in dune name: ${s[i]}`); } - return new Dune(x); } -// Function to parse a string into a SpacedDune in Node.js function spacedDunefromStr(s) { let dune = ""; let spacers = 0; - for (const c of s) { - switch (true) { - case /[A-Z]/.test(c): - dune += c; - break; - case /[.•]/.test(c): - const flag = 1 << (dune.length - 1); - if ((spacers & flag) !== 0) { - throw new Error("double spacer"); - } - spacers |= flag; - break; - default: - throw new Error("invalid character"); - } - } - - if (32 - Math.clz32(spacers) >= dune.length) { - throw new Error("trailing spacer"); - } - + if (/[A-Z]/.test(c)) dune += c; + else if (/[\u2022\u2023]/.test(c)) { + const flag = 1 << (dune.length - 1); + if ((spacers & flag) !== 0) throw new Error("double spacer"); + spacers |= flag; + } else throw new Error("invalid character"); + } + if (32 - Math.clz32(spacers) >= dune.length) throw new Error("trailing spacer"); return new SpacedDune(dune, spacers); } class Edict { - // Constructor for Edict constructor(id, amount, output) { this.id = id; this.amount = amount; @@ -289,18 +234,18 @@ class Edict { } class Terms { - constructor(limit, cap, offsetStart, offsetEnd, heightStart, heightEnd) { + constructor(limit, cap, offsetStart, offsetEnd, heightStart, heightEnd, price = null) { this.limit = limit !== undefined ? limit : null; this.cap = cap !== undefined ? cap : null; this.offsetStart = offsetStart !== undefined ? offsetStart : null; this.offsetEnd = offsetEnd !== undefined ? offsetEnd : null; this.heightStart = heightStart !== undefined ? heightStart : null; this.heightEnd = heightEnd !== undefined ? heightEnd : null; + if (price) this.price = price; } } class Etching { - // Constructor for Etching constructor(divisibility, terms, turbo, premine, dune, spacers, symbol) { this.divisibility = divisibility; this.terms = terms !== undefined ? terms : null; @@ -353,77 +298,39 @@ const STEPS = [ const SUBSIDY_HALVING_INTERVAL_10X = 2100000n; const FIRST_DUNE_HEIGHT = 5084000n; - const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -function format(formatter) { - let n = BigInt(this._value); - - if (n === 2n ** 128n - 1n) { - return formatter.write("BCGDENLQRQWDSLRUGSNLBTMFIJAV"); - } - - n += 1n; - let symbol = ""; - - while (n > 0n) { - symbol += ALPHABET.charAt(Number((n - 1n) % 26n)); - n = (n - 1n) / 26n; - } - - for (const c of symbol.split("").reverse()) { - formatter.write(c); - } -} - -const formatter = { - output: "", - write(str) { - this.output += str; - return this; - }, -}; - function minimumAtHeight(height) { const offset = BigInt(height) + 1n; - const INTERVAL = SUBSIDY_HALVING_INTERVAL_10X / 12n; - const start = FIRST_DUNE_HEIGHT; const end = start + SUBSIDY_HALVING_INTERVAL_10X; if (offset < start) { return BigInt(STEPS[12]); } - if (offset >= end) { return 0n; } const progress = offset - start; - - const length = BigInt(12 - Math.floor(Number(progress / INTERVAL))); - - const endValue = BigInt(STEPS[length - 1n]); + const length = 12 - Math.floor(Number(progress / INTERVAL)); const startValue = BigInt(STEPS[length]); + const endValue = BigInt(STEPS[length - 1]); const remainder = progress % INTERVAL; - return startValue - ((startValue - endValue) * remainder) / INTERVAL; } function encodeToTuple(n) { const tupleRepresentation = []; - - tupleRepresentation.push(Number(n & BigInt(0b0111_1111))); - - while (n > BigInt(0b0111_1111)) { - n = n / BigInt(128) - BigInt(1); + tupleRepresentation.push(Number(n & 0b01111111n)); + while (n > 0b01111111n) { + n = n / 128n - 1n; tupleRepresentation.unshift( - Number((n & BigInt(0b0111_1111)) | BigInt(0b1000_0000)) + Number((n & 0b01111111n) | 0b10000000n) ); } - return tupleRepresentation; } @@ -436,13 +343,10 @@ program const getUtxosWithDunes = []; const CHUNK_SIZE = 10; - // Helper function to process a chunk of UTXOs async function processChunk(utxosChunk, startIndex) { const promises = utxosChunk.map((utxo, index) => { console.log( - `Processing utxo number ${startIndex + index} of ${ - wallet.utxos.length - }` + `Processing utxo number ${startIndex + index} of ${wallet.utxos.length}` ); return getDunesForUtxo(`${utxo.txid}:${utxo.vout}`).then( (dunesOnUtxo) => { @@ -460,7 +364,6 @@ program } } - // Process UTXOs in chunks for (let i = 0; i < wallet.utxos.length; i += CHUNK_SIZE) { const chunk = wallet.utxos.slice(i, i + CHUNK_SIZE); await processChunk(chunk, i); @@ -480,22 +383,18 @@ program const utxos = await fetchAllUnspentOutputs(address); let balance = 0n; const utxoHashes = utxos.map((utxo) => `${utxo.txid}:${utxo.vout}`); - const chunkSize = 10; // Size of each chunk + const chunkSize = 10; - // Function to chunk the utxoHashes array const chunkedUtxoHashes = []; for (let i = 0; i < utxoHashes.length; i += chunkSize) { chunkedUtxoHashes.push(utxoHashes.slice(i, i + chunkSize)); } - // Process each chunk for (const chunk of chunkedUtxoHashes) { const allDunes = await getDunesForUtxos(chunk); - for (const dunesInfo of allDunes) { for (const singleDunesInfo of dunesInfo.dunes) { const [name, { amount }] = singleDunesInfo; - if (name === dune_name) { balance += BigInt(amount); } @@ -503,7 +402,6 @@ program } } - // Output the total balance console.log(`${balance.toString()} ${dune_name}`); }); @@ -539,7 +437,6 @@ const getUtxosWithOutDunes = async () => { }; const parseDuneId = (id, claim = false) => { - // Check if Dune ID is in the expected format const regex1 = /^\d+\:\d+$/; const regex2 = /^\d+\/\d+$/; @@ -548,17 +445,13 @@ const parseDuneId = (id, claim = false) => { `Dune ID ${id} is not in the expected format e.g. 1234:1 or 1234/1` ); - // Parse the id string to get height and index const [heightStr, indexStr] = regex1.test(id) ? id.split(":") : id.split("/"); - const height = parseInt(heightStr, 10); - const index = parseInt(indexStr, 10); + const height = BigInt(parseInt(heightStr, 10)); + const index = BigInt(parseInt(indexStr, 10)); - // Set the bits in the id using bitwise OR - let duneId = (BigInt(height) << BigInt(16)) | BigInt(index); - - // For minting set CLAIM_BIT + let duneId = (height << 16n) | index; if (claim) { - const CLAIM_BIT = BigInt(1) << BigInt(48); + const CLAIM_BIT = 1n << 48n; duneId |= CLAIM_BIT; } @@ -566,7 +459,6 @@ const parseDuneId = (id, claim = false) => { }; const createScriptWithProtocolMsg = () => { - // create an OP_RETURN script with the protocol message return new dogecore.Script().add("OP_RETURN").add(Buffer.from(IDENTIFIER)); }; @@ -580,9 +472,9 @@ program .argument("", "Amounts to send, separated by comma") .argument("", "Receiver's addresses, separated by comma") .action(async (txhash, vout, dune, decimals, amounts, addresses) => { - const amountsAsArray = amounts.split(",").map((amount) => Number(amount)); + const amountsAsArray = amounts.split(",").map((a) => Number(a)); const addressesAsArray = addresses.split(","); - if (amountsAsArray.length != addressesAsArray.length) { + if (amountsAsArray.length !== addressesAsArray.length) { console.error( `length of amounts ${amountsAsArray.length} and addresses ${addressesAsArray.length} are different` ); @@ -613,7 +505,7 @@ program try { const res = await walletSendDunesNoProtocol( address, - parseInt(utxoAmount), + parseInt(utxoAmount, 10), dune ); console.info(`Broadcasted transaction: ${JSON.stringify(res)}`); @@ -623,7 +515,6 @@ program } }); -// sends the full balance of the specified dune async function walletSendDunes( txhash, vout, @@ -635,97 +526,48 @@ async function walletSendDunes( let wallet = JSON.parse(fs.readFileSync(WALLET_PATH)); const dune_utxo = wallet.utxos.find( - (utxo) => utxo.txid == txhash && utxo.vout == vout + (u) => u.txid === txhash && u.vout === parseInt(vout, 10) ); - if (!dune_utxo) { - console.error(`utxo ${txhash}:${vout} not found`); - throw new Error(`utxo ${txhash}:${vout} not found`); - } + if (!dune_utxo) throw new Error(`utxo ${txhash}:${vout} not found`); const dunes = await getDunesForUtxo(`${dune_utxo.txid}:${dune_utxo.vout}`); - if (dunes.length == 0) throw new Error("no dunes"); + if (dunes.length === 0) throw new Error("no dunes"); - // check if the dune is in the utxo and if we have enough amount - const duneOnUtxo = dunes.find((d) => d.dune == dune); + const duneOnUtxo = dunes.find((d) => d.dune === dune); + if (!duneOnUtxo) throw new Error("dune not found"); - // Extract the numeric part from duneOnUtxo.amount as a BigInt let duneOnUtxoAmount = BigInt(duneOnUtxo.amount.match(/\d+/)[0]); - - // Add the decimals duneOnUtxoAmount *= BigInt(10 ** decimals); - if (!dune) throw new Error("dune not found"); const totalAmount = amounts.reduce( (acc, curr) => acc + BigInt(curr), - BigInt(0) + 0n ); - console.log("totalAmount", totalAmount); - if (duneOnUtxoAmount < totalAmount) throw new Error("not enough dunes"); - // Define default output where the sender receives unallocated dunes - const DEFAULT_OUTPUT = 1; - // Define output offset for receivers of dunes - const OFFSET = 2; + if (duneOnUtxoAmount < totalAmount) throw new Error("not enough dunes"); - // ask the user to confirm in the cli const response = await prompts({ type: "confirm", name: "value", - message: `Transferring ${totalAmount} of ${dune}. Are you sure you want to proceed?`, + message: `Transferring ${totalAmount} of ${dune}. Proceed?`, initial: true, }); + if (!response.value) throw new Error("Transaction aborted"); - if (!response.value) { - throw new Error("Transaction aborted"); - } - - let tx = new Transaction(); - - tx.from(dune_utxo); - - // we get the dune - const { id, divisibility, limit } = await getDune(dune); - console.log("id", id); - - // parse given id string to dune id - const duneId = parseDuneId(id); - - /** - * we have an index-offset of 2 - * - the first output (index 0) is the protocol message - * - the second output (index 1) is where we put the dunes which are on input utxos which shouldn't be transfered - * */ - const edicts = []; - for (let i = 0; i < amounts.length; i++) { - edicts.push(new Edict(duneId, amounts[i], i + OFFSET)); - } + const DEFAULT_OUTPUT = 1; + const OFFSET = 2; + const edicts = amounts.map((amt, i) => new Edict(parseDuneId(dune), amt, i + OFFSET)); - // Create payload and parse it into an OP_RETURN script with protocol message const script = constructScript(null, DEFAULT_OUTPUT, null, edicts); - - // Add output with OP_RETURN Dune assignment script - tx.addOutput( - new dogecore.Transaction.Output({ script: script, satoshis: 0 }) - ); - - // add one output to the sender for the dunes that are not transferred + const tx = new Transaction(); + tx.addOutput(new Transaction.Output({ script, satoshis: 0 })); tx.to(wallet.address, 100_000); + for (const addr of addresses) tx.to(addr, 100_000); - // the output after the protocol message will carry the dune balance if no payload is specified - for (const address of addresses) { - tx.to(address, 100_000); - } - - // we fund the tx await fund(wallet, tx); + if (tx.inputAmount < tx.outputAmount + tx.getFee()) throw new Error("not enough funds"); - if (tx.inputAmount < tx.outputAmount + tx.getFee()) { - throw new Error("not enough funds"); - } - - console.log(tx.toObject()); await broadcast(tx, true); - console.log(tx.hash); } @@ -737,161 +579,94 @@ async function walletSendDunesNoProtocol(address, utxoAmount, dune) { ); const duneOutputMap = new Map(); - for (const dune of walletBalanceFromOrd.data.dunes) { - for (const balance of dune.balances) { - duneOutputMap.set(balance.txid, { - ...balance, - dune: dune.dune, - }); + for (const d of walletBalanceFromOrd.data.dunes) { + for (const b of d.balances) { + duneOutputMap.set(b.txid, { ...b, dune: d.dune }); } } - const nonDuneUtxos = wallet.utxos.filter( - (utxo) => !duneOutputMap.has(utxo.txid) - ); + const nonDuneUtxos = wallet.utxos.filter((u) => !duneOutputMap.has(u.txid)); + if (nonDuneUtxos.length === 0) throw new Error("no utxos without dunes found"); - if (nonDuneUtxos.length === 0) { - throw new Error("no utxos without dunes found"); - } - - const gasUtxo = nonDuneUtxos.find((utxo) => utxo.satoshis > 100_000_000); - - if (!gasUtxo) { - throw new Error(`no gas utxo found`); - } - - let dunesUtxosValue = 0; const dunesUtxos = []; - - for (const utxo of wallet.utxos) { - if (dunesUtxos.length >= utxoAmount) { - break; - } - - if (duneOutputMap.has(utxo.txid)) { - const duneOutput = duneOutputMap.get(utxo.txid); - if (duneOutput.dune === dune) { - dunesUtxos.push(utxo); - dunesUtxosValue += utxo.satoshis; - } + for (const u of wallet.utxos) { + if (dunesUtxos.length >= utxoAmount) break; + if (duneOutputMap.has(u.txid) && duneOutputMap.get(u.txid).dune === dune) { + dunesUtxos.push(u); } } + if (dunesUtxos.length < utxoAmount) throw new Error("not enough dune utxos found"); - if (dunesUtxos.length < utxoAmount) { - throw new Error(`not enough dune utxos found`); - } - - const response = await prompts({ + const resp = await prompts({ type: "confirm", name: "value", - message: `Transferring ${utxoAmount} utxos of ${dune}. Are you sure you want to proceed?`, + message: `Transferring ${utxoAmount} utxos of ${dune}. Proceed?`, initial: true, }); + if (!resp.value) throw new Error("Transaction aborted"); - if (!response.value) { - throw new Error("Transaction aborted"); - } - - let tx = new Transaction(); + const tx = new Transaction(); tx.from(dunesUtxos); - tx.to(address, dunesUtxosValue); - + tx.to(address, dunesUtxos.reduce((acc, u) => acc + u.satoshis, 0)); await fund(wallet, tx); return await broadcast(tx, true); } const _mintDune = async (id, amount, receiver) => { - console.log("Minting Dune..."); - console.log(id, amount, receiver); - - // Parse given id string to dune id + console.log("Minting Dune...", id, amount, receiver); const duneId = parseDuneId(id, true); - if (amount == 0) { - const { id_, divisibility, limit } = await getDune(id); + if (amount === 0) { + const { divisibility, limit } = await getDune(id); amount = BigInt(limit) * BigInt(10 ** divisibility); } - // mint dune with encoded id, amount on output 1 const edicts = [new Edict(duneId, amount, 1)]; - console.log(edicts); - - // Create script for given dune statements const script = constructScript(null, undefined, null, edicts); - - // getting the wallet balance let wallet = JSON.parse(fs.readFileSync(WALLET_PATH)); - let balance = wallet.utxos.reduce((acc, curr) => acc + curr.satoshis, 0); - if (balance == 0) throw new Error("no funds"); - - // creating new tx - let tx = new Transaction(); + if (wallet.utxos.length === 0) throw new Error("no funds"); - // output carries the protocol message - tx.addOutput( - new dogecore.Transaction.Output({ script: script, satoshis: 0 }) - ); - - // add receiver output holding dune amount + const tx = new Transaction(); + tx.addOutput(new Transaction.Output({ script, satoshis: 0 })); tx.to(receiver, 100_000); - await fund(wallet, tx); - try { - await broadcast(tx, true); - } catch (e) { - console.log(e); - } - + await broadcast(tx, true).catch(console.log); console.log(tx.hash); }; program .command("mintDune") .description("Mint a Dune") - .argument("", "id of the dune in format block:index e.g. 5927764:2") - .argument( - "", - "amount to mint (0 takes the limit of the dune as amount)" - ) - .argument("", "address of the receiver") + .argument("", "block:index e.g. 5927764:2") + .argument("", "amount to mint (0 uses limit)") + .argument("", "receiver address") .action(_mintDune); function isSingleEmoji(str) { const emojiRegex = /[\p{Emoji}]/gu; - const matches = str.match(emojiRegex); - return matches ? matches.length === 1 : false; } program .command("deployOpenDune") - .description("Deploy a Dune that is open for mint") + .description("Deploy a Dune that is open for mint with optional parent inscription") .argument("", "Tick for the dune") - .argument("", "symbol") - .argument("", "Max amount that can be minted in one transaction") - .argument("", "divisibility of the dune. Max 38") - .argument("", "Max limit that can be minted overall") - .argument("", "Absolute block height where minting opens") - .argument("", "Absolute block height where minting closes") - .argument("", "Relative block height where minting opens") - .argument( - "", - "Relative block height where minting closes (former known as term)" - ) - .argument( - "", - "Amount of allocated dunes to the etcher while etching" - ) - .argument( - "", - "Marks this etching as opting into future protocol changes." - ) - .argument( - "", - "Set this to true to allow minting, taking terms (limit, cap, height, offset) as restrictions" - ) + .argument("", "Symbol") + .argument("", "Max mint per tx") + .argument("", "Divisibility") + .argument("", "Overall mint cap or 'null'") + .argument("", "Open height or 'null'") + .argument("", "Close height or 'null'") + .argument("", "Open offset or 'null'") + .argument("", "Close offset or 'null'") + .argument("", "Premine amount or 'null'") + .argument("", "Turbo flag true/false") + .argument("", "Enable minting true/false") + .argument("[parentId]", "Optional parent inscription ID") + .argument("", "Mint price in shibes or 'null'") + .argument("", "Pay-to address or 'null'") .action( async ( tick, @@ -905,61 +680,41 @@ program offsetEnd, premine, turbo, - openMint + openMint, + parentId, + priceAmount, + pricePayTo ) => { - console.log("Deploying open Dune..."); - console.log( - tick, - symbol, - limit, - divisibility, - cap, - heightStart, - heightEnd, - offsetStart, - offsetEnd, - premine, - turbo, - openMint - ); - + console.log("Deploying open Dune with pay terms…"); cap = cap === "null" ? null : cap; heightStart = heightStart === "null" ? null : heightStart; heightEnd = heightEnd === "null" ? null : heightEnd; offsetStart = offsetStart === "null" ? null : offsetStart; offsetEnd = offsetEnd === "null" ? null : offsetEnd; premine = premine === "null" ? null : premine; - turbo = turbo === "null" ? null : turbo === "true"; - + turbo = turbo === "true"; openMint = openMint.toLowerCase() === "true"; - if (symbol) { - if (symbol.length !== 1 && !isSingleEmoji(symbol)) { - console.error( - `Error: The argument symbol should have exactly 1 character, but is '${symbol}'` - ); - process.exit(1); - } + if (symbol && symbol.length !== 1 && !isSingleEmoji(symbol)) { + console.error(`Error: Symbol must be 1 character, got '${symbol}'`); + process.exit(1); } const spacedDune = spacedDunefromStr(tick); - - const blockcount = await getblockcount(); - const mininumAtCurrentHeight = minimumAtHeight(blockcount.data.result); - - if (spacedDune.dune.value < mininumAtCurrentHeight) { - const minAtCurrentHeightObj = { _value: mininumAtCurrentHeight }; - format.call(minAtCurrentHeightObj, formatter); + const { data: blockRes } = await getblockcount(); + const minAtCurrent = minimumAtHeight(blockRes.result); + if (spacedDune.dune.value < minAtCurrent) { console.error("Dune characters are invalid at current height."); - process.stdout.write( - `minimum at current height: ${mininumAtCurrentHeight} possible lowest tick: ${formatter.output}\n` - ); - console.log(`dune: ${tick} value: ${spacedDune.dune.value}`); process.exit(1); } + let price = null; + if (priceAmount !== "null" && pricePayTo !== "null") { + price = { amount: priceAmount, pay_to: pricePayTo }; + } + const terms = openMint - ? new Terms(limit, cap, offsetStart, offsetEnd, heightStart, heightEnd) + ? new Terms(limit, cap, offsetStart, offsetEnd, heightStart, heightEnd, price) : null; const etching = new Etching( @@ -969,794 +724,253 @@ program premine, spacedDune.dune.value, spacedDune.spacers, - symbol.codePointAt() + symbol.codePointAt(0) ); - // create script for given dune statements const script = constructScript(etching, undefined, null, null); - // getting the wallet balance - let wallet = JSON.parse(fs.readFileSync(WALLET_PATH)); - let balance = wallet.utxos.reduce((acc, curr) => acc + curr.satoshis, 0); - if (balance == 0) throw new Error("no funds"); + const wallet = JSON.parse(fs.readFileSync(WALLET_PATH)); + let balance = wallet.utxos.reduce((a, c) => a + c.satoshis, 0); + if (balance === 0) throw new Error("no funds"); - // creating new tx let tx = new Transaction(); - // first output carries the protocol message - tx.addOutput( - new dogecore.Transaction.Output({ script: script, satoshis: 0 }) - ); + if (parentId) { + const parentUtxo = await fetchParentUtxo(parentId); + tx.from(parentUtxo); + console.log(`Added parent UTXO ${parentId} to transaction`); + } - // Create second output to sender if dunes are directly allocated in etching + tx.addOutput(new Transaction.Output({ script, satoshis: 0 })); if (premine > 0) tx.to(wallet.address, 100_000); await fund(wallet, tx); - await broadcast(tx, true); + if (tx.inputAmount < tx.outputAmount + tx.getFee()) { + throw new Error("not enough funds to cover outputs and fee"); + } - console.log(tx.hash); + await broadcast(tx, true); + console.log(`Dune deployed with tx hash: ${tx.hash}`); } ); -async function parseScriptString(scriptString) { - const parts = scriptString.split(" "); - - // Check if there is an OP_RETURN contained in the script string - if (parts.indexOf("OP_RETURN") === -1) { - throw new Error("No OP_RETURN output"); - } - - // Find the indices of the 'OP_PUSHBYTES' instructions - const pushBytesIndices = parts.reduce((indices, part, index) => { - if (part.startsWith("OP_PUSHBYTES")) { - indices.push(index + 1); - } - return indices; - }, []); - - // If 'OP_PUSHBYTES' not found, assume we got 'OP_RETURN identifier msg' format - if (pushBytesIndices.length < 2) { - pushBytesIndices.push(1); - pushBytesIndices.push(2); - } - - // Extract identifier and message - const identifier = parts[pushBytesIndices[0]]; - - /** - * Check Protocol Identifier - * Ord and most other explorers show this in hex representation. - * Some explorers show this in decimal representation, - * therefore we check both. - **/ - if (identifier != IDENTIFIER) { - if (parseInt(identifier, 16) != IDENTIFIER) { - throw new Error("Couldn't find correct Protocol Identifier."); - } - } - - const msg = parts[pushBytesIndices[1]]; - - // Parse msg to payload bytes - const payload = []; - for (let i = 0; i < msg.length; i += 2) { - payload.push(parseInt(msg.substr(i, 2), 16)); - } - - return payload; +// Helpers: fetchParentUtxo, getrawtx, getblockcount, broadcast, fund, updateWallet, fetchAllUnspentOutputs, getDunesForUtxos, getDunesForUtxo, getDune, retryAsync, wallet commands… + +async function fetchParentUtxo(parentId) { + const [txid, i] = parentId.split("i"); + const vout = parseInt(i, 10); + const raw = await getrawtx(txid); + const output = raw.data.result.vout[vout]; + return { + txid, + vout, + script: output.scriptPubKey.hex, + satoshis: Math.round(output.value * 1e8), + }; } -async function decodePayload(payload) { - const integers = []; - let i = 0; - - while (i < payload.length) { - const [integer, length] = varIntDecode(payload.slice(i)); - integers.push(integer); - i += length; - } - - return integers; +async function getrawtx(txid) { + const body = { jsonrpc: "1.0", id: 0, method: "getrawtransaction", params: [txid, true] }; + const opts = { auth: { username: process.env.NODE_RPC_USER, password: process.env.NODE_RPC_PASS } }; + return await axios.post(process.env.NODE_RPC_URL, body, opts); } -function varIntDecode(buffer) { - let n = 0n; - let i = 0; - - while (true) { - if (i < buffer.length) { - const b = BigInt(parseInt(buffer[i], 10)); - n = n * 128n; - - if (b < 128) { - return [n + b, i + 1]; - } - - n = n + b - 127n; - - i++; - } else { - return [n, i]; - } - } +async function getblockcount() { + const body = { jsonrpc: "1.0", id: 0, method: "getblockcount", params: [] }; + const opts = { auth: { username: process.env.NODE_RPC_USER, password: process.env.NODE_RPC_PASS } }; + return await axios.post(process.env.NODE_RPC_URL, body, opts); } -function parseIntegers(integers) { - const edicts = []; - const fields = {}; - - for (let i = 0; i < integers.length; i += 2) { - const tag = integers[i]; - if (tag === BigInt(Tag.Body)) { - let id = 0n; - for (let j = i + 1; j < integers.length; j += 3) { - const chunk = integers.slice(j, j + 3); - id = id + BigInt(parseInt(chunk[0], 10)); - edicts.push({ - id, - amount: chunk[1], - output: chunk[2], - }); - } - break; +async function broadcast(tx, retry) { + const body = { jsonrpc: "1.0", id: 0, method: "sendrawtransaction", params: [tx.toString()] }; + const opts = { auth: { username: process.env.NODE_RPC_USER, password: process.env.NODE_RPC_PASS } }; + const makePostRequest = async () => { + try { + return await axios.post(process.env.NODE_RPC_URL, body, opts); + } catch { + return await axios.post(process.env.FALLBACK_NODE_RPC_URL || process.env.NODE_RPC_URL, body, opts); } - - if (i + 1 <= integers.length) { - const value = integers[i + 1]; - if (!fields[tag]) fields[tag] = value; - } else { + }; + let res; + while (true) { + try { + res = await retryAsync(makePostRequest, 10, 30000); break; + } catch (e) { + if (!retry) throw e; + const msg = e.response?.data?.error?.message; + if (msg?.includes("too-long-mempool-chain")) { + console.warn("retrying in 15 secs, too-long-mempool-chain"); + const blockRes = await getblockcount(); + console.log(`Block is ${blockRes.data.result}`); + await new Promise((r) => setTimeout(r, 15000)); + } else { + await walletSync(); + console.log(`Made a wallet sync for address ${JSON.parse(fs.readFileSync(WALLET_PATH)).address}`); + throw e; + } } } - - return { fields, edicts }; + const wallet = JSON.parse(fs.readFileSync(WALLET_PATH)); + updateWallet(wallet, tx); + fs.writeFileSync(WALLET_PATH, JSON.stringify(wallet, null, 2)); + return res.data; } -function writeDuneWithSpacers(dune, spacers) { - let output = ""; - - for (let i = 0n; i < dune.length; i++) { - const c = dune[i]; - output += c; - - if (spacers && i < dune.length - 1 && spacers & (1n << i)) { - output += "•"; - } +async function fund(wallet, tx, onlySafeUtxos = true) { + const utxos = onlySafeUtxos ? await getUtxosWithOutDunes() : wallet.utxos; + const sorted = utxos.slice().sort((a, b) => b.satoshis - a.satoshis); + const large = sorted.filter((u) => u.satoshis >= 1_000_000); + const needed = tx.outputs.reduce((a, o) => a + o.satoshis, 0); + let added = 0n, haveChange = false; + for (const u of large) { + if (added >= needed + BigInt(tx._estimateFee())) break; + tx.from(u); + delete tx._fee; + tx.change(wallet.address); + haveChange = true; + added += BigInt(u.satoshis); } - - return output; + tx._fee = tx._estimateFee(); + tx.sign(wallet.privkey); + if (!haveChange) throw new Error("no change output added"); + if (tx.inputAmount < tx.outputAmount + tx.getFee()) throw new Error("not enough (secure) funds"); } -// todo: this needs an update for the protocol changes -program - .command("decodeDunesScript") - .description("Decode an OP_RETURN Dunes Script") - .argument("