@neuraiproject/neurai-assets 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,23 +96,23 @@ const result = await assets.reissueAsset({
96
96
  ### Create UNIQUE Assets (NFTs)
97
97
 
98
98
  ```javascript
99
+ // Without IPFS metadata
99
100
  const result = await assets.createUniqueAssets({
100
- rootAssetName: 'MYTOKEN',
101
- assetTags: [
102
- {
103
- tag: 'NFT001',
104
- hasIpfs: true,
105
- ipfsHash: 'QmNFT1...'
106
- },
107
- {
108
- tag: 'NFT002',
109
- hasIpfs: true,
110
- ipfsHash: 'QmNFT2...'
111
- }
112
- ]
101
+ rootName: 'MYTOKEN',
102
+ assetTags: ['NFT001', 'NFT002', 'NFT003']
103
+ });
104
+
105
+ // With IPFS metadata (ipfsHashes must be same length as assetTags)
106
+ const result = await assets.createUniqueAssets({
107
+ rootName: 'MYTOKEN',
108
+ assetTags: ['NFT001', 'NFT002'],
109
+ ipfsHashes: ['QmNFT1...', 'QmNFT2...']
113
110
  });
114
111
  ```
115
112
 
113
+ > **Note**: UNIQUE asset properties (`units`, `reissuable`) are always `0` and are
114
+ > set automatically by the node — they cannot be configured per asset.
115
+
116
116
  ### Create QUALIFIER (KYC Tags)
117
117
 
118
118
  ```javascript
@@ -363,6 +363,11 @@ When you create an asset, an **owner token** is automatically generated (e.g., `
363
363
 
364
364
  The library automatically validates that the owner token is returned in each operation to prevent accidental loss.
365
365
 
366
+ > **UNIQUE assets exception**: When issuing UNIQUE assets (`ROOT#TAG`), the Neurai node
367
+ > returns the owner token automatically as part of processing the `issue_unique` operation.
368
+ > The library does not add a manual return output for this case — doing so would duplicate
369
+ > the owner token in the outputs and cause the transaction to fail with "Assets would be burnt".
370
+
366
371
  ## Operation Costs
367
372
 
368
373
  | Operation | Cost (XNA burned) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuraiproject/neurai-assets",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Non-custodial Neurai asset management library for JavaScript",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -167,7 +167,8 @@ class NeuraiAssets {
167
167
  * @returns {Promise<object>} Transaction data
168
168
  */
169
169
  async createQualifier(params) {
170
- const builder = new IssueQualifierBuilder(this.rpc, this._buildParams(params));
170
+ const normalized = { ...params, assetName: params.assetName || params.qualifierName };
171
+ const builder = new IssueQualifierBuilder(this.rpc, this._buildParams(normalized));
171
172
  return await builder.build();
172
173
  }
173
174
 
@@ -47,14 +47,7 @@ class FreezeAddressBuilder extends BaseAssetTransactionBuilder {
47
47
  );
48
48
  }
49
49
 
50
- // Basic address validation
51
- const validPrefixes = this.network === 'xna' ? ['N'] : ['m', 'n'];
52
- if (!validPrefixes.some(prefix => address.startsWith(prefix))) {
53
- throw new InvalidAddressError(
54
- `addresses[${index}] has invalid prefix for network ${this.network}`,
55
- address
56
- );
57
- }
50
+ // Address prefix validation is left to the node (varies by network)
58
51
  });
59
52
  }
60
53
 
@@ -162,13 +155,6 @@ class FreezeAddressBuilder extends BaseAssetTransactionBuilder {
162
155
  outputs.push({ [changeAddress]: parseFloat(xnaChange.toFixed(8)) });
163
156
  }
164
157
 
165
- // Second: Owner token return (CRITICAL - must return or lost forever!)
166
- const ownerTokenReturn = this.ownerTokenManager.createOwnerTokenReturnOutput(
167
- ownerTokenName,
168
- changeAddress
169
- );
170
- outputs.push(ownerTokenReturn);
171
-
172
158
  // Last: Freeze/Unfreeze operation
173
159
  let operationOutput;
174
160
  let targetAddresses = [];
@@ -180,7 +166,7 @@ class FreezeAddressBuilder extends BaseAssetTransactionBuilder {
180
166
  asset_name: assetName,
181
167
  addresses: targetAddresses
182
168
  });
183
- outputs.push({ [targetAddresses[0]]: operationOutput });
169
+ outputs.push({ [changeAddress]: operationOutput });
184
170
  break;
185
171
 
186
172
  case 'UNFREEZE_ADDRESSES':
@@ -189,7 +175,7 @@ class FreezeAddressBuilder extends BaseAssetTransactionBuilder {
189
175
  asset_name: assetName,
190
176
  addresses: targetAddresses
191
177
  });
192
- outputs.push({ [targetAddresses[0]]: operationOutput });
178
+ outputs.push({ [changeAddress]: operationOutput });
193
179
  break;
194
180
 
195
181
  case 'FREEZE_ASSET':
@@ -209,13 +195,10 @@ class FreezeAddressBuilder extends BaseAssetTransactionBuilder {
209
195
  // 13. Order outputs (protocol requirement)
210
196
  const orderedOutputs = this.outputOrderer.order(outputs);
211
197
 
212
- // 14. Validate owner token is returned (safety check)
213
- this.ownerTokenManager.validateOwnerTokenReturn(inputs, orderedOutputs);
214
-
215
- // 15. Create raw transaction
198
+ // 14. Create raw transaction
216
199
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
217
200
 
218
- // 16. Format and return result
201
+ // 15. Format and return result
219
202
  const allUTXOs = [...baseCurrencyUTXOs, ownerTokenUTXO];
220
203
 
221
204
  return this.formatResult(
@@ -9,7 +9,8 @@
9
9
  * - Quantity: 1-10 units only
10
10
  * - Units: Always 0 (non-divisible)
11
11
  * - Used to tag addresses for restricted asset compliance
12
- * - Creates owner token (#QUALIFIER!)
12
+ * - Root qualifiers do not create owner tokens
13
+ * - Sub-qualifiers consume and return the parent qualifier asset itself
13
14
  */
14
15
 
15
16
  const BaseAssetTransactionBuilder = require('./BaseAssetTransactionBuilder');
@@ -75,13 +76,14 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
75
76
  const isSub = this.isSubQualifier(assetName);
76
77
  const parsed = AssetNameParser.parse(assetName);
77
78
 
78
- // 3. If sub-qualifier, check parent exists and get owner token
79
- let ownerTokenUTXO = null;
80
- let ownerTokenName = null;
79
+ // 3. If sub-qualifier, check parent exists and get parent qualifier input
80
+ let parentQualifierUTXOs = [];
81
+ let parentQualifierQuantity = null;
82
+ let parentQualifierName = null;
81
83
  const addresses = await this._getAddresses();
82
84
 
83
85
  if (isSub) {
84
- const parentQualifierName = parsed.parent;
86
+ parentQualifierName = parsed.parent;
85
87
 
86
88
  // Check parent qualifier exists
87
89
  const parentExists = await this.assetExists(parentQualifierName);
@@ -92,18 +94,16 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
92
94
  );
93
95
  }
94
96
 
95
- // Find parent's owner token
96
- ownerTokenName = AssetNameParser.getOwnerTokenName(parentQualifierName);
97
+ // Find parent qualifier balance to spend and return as change
97
98
  try {
98
- ownerTokenUTXO = await this.ownerTokenManager.findOwnerTokenUTXO(
99
- ownerTokenName,
100
- addresses
101
- );
99
+ const selection = await this.utxoSelector.selectAssetUTXOs(addresses, parentQualifierName, 1);
100
+ parentQualifierUTXOs = selection.utxos;
101
+ parentQualifierQuantity = selection.totalAmount;
102
102
  } catch (error) {
103
- if (error instanceof OwnerTokenNotFoundError) {
103
+ if (error.name === 'InsufficientFundsError') {
104
104
  throw new OwnerTokenNotFoundError(
105
- `You must own the parent qualifier's owner token (${ownerTokenName}) to create a sub-qualifier.`,
106
- ownerTokenName
105
+ `You must own the parent qualifier asset (${parentQualifierName}) to create a sub-qualifier.`,
106
+ parentQualifierName
107
107
  );
108
108
  }
109
109
  throw error;
@@ -129,7 +129,7 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
129
129
  const changeAddress = await this.getChangeAddress();
130
130
 
131
131
  // 7. Estimate fee
132
- const outputCount = isSub ? 4 : 3; // Sub has owner token return
132
+ const outputCount = 3;
133
133
  const estimatedFee = await this.estimateFee(2, outputCount);
134
134
 
135
135
  // 8. Calculate total XNA needed
@@ -141,7 +141,7 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
141
141
  const totalXNAInput = utxoSelection.totalXNA;
142
142
 
143
143
  // 10. Recalculate fee with actual input count
144
- const actualInputCount = baseCurrencyUTXOs.length + (ownerTokenUTXO ? 1 : 0);
144
+ const actualInputCount = baseCurrencyUTXOs.length + parentQualifierUTXOs.length;
145
145
  const actualFee = await this.estimateFee(actualInputCount, outputCount);
146
146
 
147
147
  // 11. Verify we have enough XNA
@@ -172,16 +172,16 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
172
172
  });
173
173
  });
174
174
 
175
- // Add owner token input if sub-qualifier
176
- if (ownerTokenUTXO) {
175
+ // Add parent qualifier inputs if sub-qualifier
176
+ parentQualifierUTXOs.forEach(parentUTXO => {
177
177
  inputs.push({
178
- txid: ownerTokenUTXO.txid,
179
- vout: ownerTokenUTXO.outputIndex,
180
- address: ownerTokenUTXO.address,
181
- assetName: ownerTokenUTXO.assetName,
182
- satoshis: ownerTokenUTXO.satoshis
178
+ txid: parentUTXO.txid,
179
+ vout: parentUTXO.outputIndex,
180
+ address: parentUTXO.address,
181
+ assetName: parentUTXO.assetName,
182
+ satoshis: parentUTXO.satoshis
183
183
  });
184
- }
184
+ });
185
185
 
186
186
  // 14. Build outputs (ORDER CRITICAL!)
187
187
  const outputs = [];
@@ -194,21 +194,14 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
194
194
  outputs.push({ [changeAddress]: parseFloat(xnaChange.toFixed(8)) });
195
195
  }
196
196
 
197
- // Third: Owner token return (if sub-qualifier)
198
- if (ownerTokenUTXO && ownerTokenName) {
199
- const ownerTokenReturn = this.ownerTokenManager.createOwnerTokenReturnOutput(
200
- ownerTokenName,
201
- changeAddress
202
- );
203
- outputs.push(ownerTokenReturn);
204
- }
205
-
206
197
  // Last: Issue qualifier operation
207
198
  const issueQualifierOutput = OutputFormatter.formatIssueQualifierOutput({
208
199
  asset_name: assetName,
209
200
  asset_quantity: quantity,
210
201
  has_ipfs: hasIpfs,
211
- ipfs_hash: ipfsHash
202
+ ipfs_hash: ipfsHash,
203
+ root_change_address: isSub ? changeAddress : undefined,
204
+ change_quantity: isSub ? parentQualifierQuantity : undefined
212
205
  });
213
206
 
214
207
  outputs.push({ [toAddress]: issueQualifierOutput });
@@ -216,18 +209,11 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
216
209
  // 15. Order outputs (protocol requirement)
217
210
  const orderedOutputs = this.outputOrderer.order(outputs);
218
211
 
219
- // 16. Validate owner token is returned if sub-qualifier
220
- if (ownerTokenUTXO) {
221
- this.ownerTokenManager.validateOwnerTokenReturn(inputs, orderedOutputs);
222
- }
223
-
224
- // 17. Create raw transaction
212
+ // 16. Create raw transaction
225
213
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
226
214
 
227
- // 18. Format and return result
228
- const allUTXOs = ownerTokenUTXO
229
- ? [...baseCurrencyUTXOs, ownerTokenUTXO]
230
- : baseCurrencyUTXOs;
215
+ // 17. Format and return result
216
+ const allUTXOs = [...baseCurrencyUTXOs, ...parentQualifierUTXOs];
231
217
 
232
218
  return this.formatResult(
233
219
  rawTx,
@@ -240,8 +226,7 @@ class IssueQualifierBuilder extends BaseAssetTransactionBuilder {
240
226
  assetName,
241
227
  qualifierType: isSub ? 'SUB_QUALIFIER' : 'QUALIFIER',
242
228
  parentQualifier: isSub ? parsed.parent : null,
243
- ownerTokenName: assetName + '!',
244
- parentOwnerTokenUsed: ownerTokenName,
229
+ parentQualifierUsed: parentQualifierName,
245
230
  operationType: isSub ? 'ISSUE_SUB_QUALIFIER' : 'ISSUE_QUALIFIER'
246
231
  }
247
232
  );
@@ -9,12 +9,12 @@
9
9
  * - Requires verifier string (boolean logic with qualifiers)
10
10
  * - Only addresses meeting verifier requirements can receive/hold
11
11
  * - Can freeze individual addresses or entire asset
12
- * - Creates owner token ($ASSET!)
12
+ * - Creates owner token (ASSET!)
13
13
  */
14
14
 
15
15
  const BaseAssetTransactionBuilder = require('./BaseAssetTransactionBuilder');
16
- const { OutputFormatter } = require('../utils');
17
- const { AssetExistsError } = require('../errors');
16
+ const { OutputFormatter, AssetNameParser } = require('../utils');
17
+ const { AssetExistsError, OwnerTokenNotFoundError } = require('../errors');
18
18
  const { IpfsValidator, VerifierValidator } = require('../validators');
19
19
 
20
20
  class IssueRestrictedBuilder extends BaseAssetTransactionBuilder {
@@ -98,21 +98,39 @@ class IssueRestrictedBuilder extends BaseAssetTransactionBuilder {
98
98
  const toAddress = await this.getToAddress();
99
99
  const changeAddress = await this.getChangeAddress();
100
100
 
101
- // 6. Estimate fee
102
- const estimatedFee = await this.estimateFee(1, 3);
101
+ // 6. Find owner token UTXO (CRITICAL: node requires it as input)
102
+ const ownerTokenName = AssetNameParser.getOwnerTokenName(assetName);
103
+ let ownerTokenUTXO;
104
+ try {
105
+ ownerTokenUTXO = await this.ownerTokenManager.findOwnerTokenUTXO(
106
+ ownerTokenName,
107
+ addresses
108
+ );
109
+ } catch (error) {
110
+ if (error instanceof OwnerTokenNotFoundError) {
111
+ throw new OwnerTokenNotFoundError(
112
+ `You must own the owner token (${ownerTokenName}) to issue the restricted asset ${assetName}.`,
113
+ ownerTokenName
114
+ );
115
+ }
116
+ throw error;
117
+ }
103
118
 
104
- // 7. Calculate total XNA needed
119
+ // 7. Estimate fee (+1 for owner token input)
120
+ const estimatedFee = await this.estimateFee(2, 4);
121
+
122
+ // 8. Calculate total XNA needed
105
123
  const totalXNANeeded = burnInfo.amount + estimatedFee;
106
124
 
107
- // 8. Select XNA UTXOs
125
+ // 9. Select XNA UTXOs
108
126
  const utxoSelection = await this.selectUTXOs(totalXNANeeded, null, 0);
109
127
  const baseCurrencyUTXOs = utxoSelection.xnaUTXOs;
110
128
  const totalXNAInput = utxoSelection.totalXNA;
111
129
 
112
- // 9. Recalculate fee with actual input count
113
- const actualFee = await this.estimateFee(baseCurrencyUTXOs.length, 3);
130
+ // 10. Recalculate fee with actual input count (+1 for owner token)
131
+ const actualFee = await this.estimateFee(baseCurrencyUTXOs.length + 1, 4);
114
132
 
115
- // 10. Verify we have enough XNA
133
+ // 11. Verify we have enough XNA
116
134
  const totalRequired = burnInfo.amount + actualFee;
117
135
  if (totalXNAInput < totalRequired) {
118
136
  const additionalNeeded = totalRequired - totalXNAInput + 0.001;
@@ -120,22 +138,35 @@ class IssueRestrictedBuilder extends BaseAssetTransactionBuilder {
120
138
  baseCurrencyUTXOs.push(...additionalSelection.xnaUTXOs);
121
139
  }
122
140
 
123
- // 11. Calculate XNA change
141
+ // 12. Calculate XNA change
124
142
  const finalTotalInput = baseCurrencyUTXOs.reduce(
125
143
  (sum, utxo) => sum + utxo.satoshis / 100000000,
126
144
  0
127
145
  );
128
146
  const xnaChange = finalTotalInput - burnInfo.amount - actualFee;
129
147
 
130
- // 12. Build inputs
131
- const inputs = baseCurrencyUTXOs.map(utxo => ({
132
- txid: utxo.txid,
133
- vout: utxo.outputIndex,
134
- address: utxo.address,
135
- satoshis: utxo.satoshis
136
- }));
148
+ // 13. Build inputs (XNA + owner token)
149
+ const inputs = [];
137
150
 
138
- // 13. Build outputs (ORDER CRITICAL!)
151
+ baseCurrencyUTXOs.forEach(utxo => {
152
+ inputs.push({
153
+ txid: utxo.txid,
154
+ vout: utxo.outputIndex,
155
+ address: utxo.address,
156
+ satoshis: utxo.satoshis
157
+ });
158
+ });
159
+
160
+ // Add owner token input (node requires it to issue restricted asset)
161
+ inputs.push({
162
+ txid: ownerTokenUTXO.txid,
163
+ vout: ownerTokenUTXO.outputIndex,
164
+ address: ownerTokenUTXO.address,
165
+ assetName: ownerTokenUTXO.assetName,
166
+ satoshis: ownerTokenUTXO.satoshis
167
+ });
168
+
169
+ // 14. Build outputs (ORDER CRITICAL!)
139
170
  const outputs = [];
140
171
 
141
172
  // First: Burn output
@@ -154,28 +185,31 @@ class IssueRestrictedBuilder extends BaseAssetTransactionBuilder {
154
185
  units: units,
155
186
  reissuable: reissuable,
156
187
  has_ipfs: hasIpfs,
157
- ipfs_hash: ipfsHash
188
+ ipfs_hash: ipfsHash,
189
+ owner_change_address: changeAddress
158
190
  });
159
191
 
160
192
  outputs.push({ [toAddress]: issueRestrictedOutput });
161
193
 
162
- // 14. Order outputs (protocol requirement)
194
+ // 15. Order outputs (protocol requirement)
163
195
  const orderedOutputs = this.outputOrderer.order(outputs);
164
196
 
165
- // 15. Create raw transaction
197
+ // 16. Create raw transaction
166
198
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
167
199
 
168
- // 16. Format and return result
200
+ // 17. Format and return result
201
+ const allUTXOs = [...baseCurrencyUTXOs, ownerTokenUTXO];
202
+
169
203
  return this.formatResult(
170
204
  rawTx,
171
- baseCurrencyUTXOs,
205
+ allUTXOs,
172
206
  inputs,
173
207
  orderedOutputs,
174
208
  actualFee,
175
209
  burnInfo.amount,
176
210
  {
177
211
  assetName,
178
- ownerTokenName: assetName + '!',
212
+ ownerTokenName,
179
213
  verifierString,
180
214
  requiredQualifiers,
181
215
  operationType: 'ISSUE_RESTRICTED'
@@ -202,14 +202,9 @@ class IssueUniqueBuilder extends BaseAssetTransactionBuilder {
202
202
  outputs.push({ [changeAddress]: parseFloat(xnaChange.toFixed(8)) });
203
203
  }
204
204
 
205
- // Third: Owner token return (CRITICAL - must return or lost forever!)
206
- const ownerTokenReturn = this.ownerTokenManager.createOwnerTokenReturnOutput(
207
- ownerTokenName,
208
- changeAddress
209
- );
210
- outputs.push(ownerTokenReturn);
211
-
212
205
  // Last: Issue unique operation
206
+ // NOTE: owner token return is handled automatically by the node when processing
207
+ // issue_unique — adding it manually would cause TOKEN! to appear twice in outputs
213
208
  const issueUniqueOutput = OutputFormatter.formatIssueUniqueOutput({
214
209
  root_name: rootName,
215
210
  asset_tags: assetTags,
@@ -221,16 +216,14 @@ class IssueUniqueBuilder extends BaseAssetTransactionBuilder {
221
216
  // 15. Order outputs (protocol requirement)
222
217
  const orderedOutputs = this.outputOrderer.order(outputs);
223
218
 
224
- // 16. Validate owner token is returned (safety check)
225
- this.ownerTokenManager.validateOwnerTokenReturn(inputs, orderedOutputs);
226
-
227
- // 17. Create raw transaction
219
+ // 16. Create raw transaction
220
+ // NOTE: validateOwnerTokenReturn removed — the node returns TOKEN! automatically
228
221
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
229
222
 
230
- // 18. Build list of created NFT names
223
+ // 17. Build list of created NFT names
231
224
  const createdNFTs = assetTags.map(tag => `${rootName}#${tag}`);
232
225
 
233
- // 19. Format and return result
226
+ // 18. Format and return result
234
227
  const allUTXOs = [...baseCurrencyUTXOs, ownerTokenUTXO];
235
228
 
236
229
  return this.formatResult(
@@ -200,7 +200,8 @@ class ReissueBuilder extends BaseAssetTransactionBuilder {
200
200
  asset_name: assetName,
201
201
  asset_quantity: this.toSatoshis(quantity, units),
202
202
  reissuable: reissuable !== undefined ? reissuable : undefined,
203
- new_ipfs: newIpfs || undefined
203
+ new_ipfs: newIpfs || undefined,
204
+ owner_change_address: changeAddress
204
205
  });
205
206
 
206
207
  outputs.push({ [toAddress]: reissueOutput });
@@ -5,7 +5,7 @@
5
5
  * Reissue Restricted:
6
6
  * - Mints additional supply of restricted asset
7
7
  * - Cost: 200 XNA (burned)
8
- * - Requires asset's owner token ($ASSET!)
8
+ * - Requires asset's owner token (ASSET!)
9
9
  * - Can update verifier string
10
10
  * - Can lock asset (make it non-reissuable)
11
11
  * - Can update IPFS metadata
@@ -200,14 +200,6 @@ class ReissueRestrictedBuilder extends BaseAssetTransactionBuilder {
200
200
  outputs.push({ [changeAddress]: parseFloat(xnaChange.toFixed(8)) });
201
201
  }
202
202
 
203
- // Third: Owner token return (CRITICAL - must return or lost forever!)
204
- const ownerReturnAddress = this.params.ownerChangeAddress || changeAddress;
205
- const ownerTokenReturn = this.ownerTokenManager.createOwnerTokenReturnOutput(
206
- ownerTokenName,
207
- ownerReturnAddress
208
- );
209
- outputs.push(ownerTokenReturn);
210
-
211
203
  // Last: Reissue restricted operation
212
204
  const units = assetData.units || 0;
213
205
  const reissueRestrictedOutput = OutputFormatter.formatReissueRestrictedOutput({
@@ -216,7 +208,8 @@ class ReissueRestrictedBuilder extends BaseAssetTransactionBuilder {
216
208
  change_verifier: changeVerifier,
217
209
  new_verifier: changeVerifier ? newVerifier : undefined,
218
210
  reissuable: reissuable !== undefined ? reissuable : undefined,
219
- new_ipfs: newIpfs || undefined
211
+ new_ipfs: newIpfs || undefined,
212
+ owner_change_address: this.params.ownerChangeAddress || changeAddress
220
213
  });
221
214
 
222
215
  outputs.push({ [toAddress]: reissueRestrictedOutput });
@@ -224,13 +217,10 @@ class ReissueRestrictedBuilder extends BaseAssetTransactionBuilder {
224
217
  // 16. Order outputs (protocol requirement)
225
218
  const orderedOutputs = this.outputOrderer.order(outputs);
226
219
 
227
- // 17. Validate owner token is returned (safety check)
228
- this.ownerTokenManager.validateOwnerTokenReturn(inputs, orderedOutputs);
229
-
230
- // 18. Create raw transaction
220
+ // 17. Create raw transaction
231
221
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
232
222
 
233
- // 19. Format and return result
223
+ // 18. Format and return result
234
224
  const allUTXOs = [...baseCurrencyUTXOs, ownerTokenUTXO];
235
225
 
236
226
  // Extract qualifiers from new verifier if changed
@@ -6,13 +6,13 @@
6
6
  * - Assign qualifier tags to addresses (for restricted asset compliance)
7
7
  * - Remove qualifier tags from addresses
8
8
  * - Cost: 0.1 XNA per address (burned)
9
- * - Requires qualifier's owner token (#QUALIFIER!)
9
+ * - Requires spending the qualifier asset itself (#QUALIFIER)
10
10
  * - Used to mark addresses as KYC'd, accredited, etc.
11
11
  * - Owner token must be returned
12
12
  */
13
13
 
14
14
  const BaseAssetTransactionBuilder = require('./BaseAssetTransactionBuilder');
15
- const { OutputFormatter, AssetNameParser } = require('../utils');
15
+ const { OutputFormatter } = require('../utils');
16
16
  const { AssetNotFoundError, OwnerTokenNotFoundError, InvalidAddressError } = require('../errors');
17
17
 
18
18
  class TagAddressBuilder extends BaseAssetTransactionBuilder {
@@ -32,6 +32,10 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
32
32
  throw new Error('addresses is required and must be a non-empty array');
33
33
  }
34
34
 
35
+ if (params.addresses.length > 10) {
36
+ throw new Error('addresses array cannot exceed 10 entries per transaction (node limit)');
37
+ }
38
+
35
39
  // Validate qualifier name
36
40
  this.validateAssetName(params.qualifierName, 'QUALIFIER');
37
41
 
@@ -44,14 +48,7 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
44
48
  );
45
49
  }
46
50
 
47
- // Basic address validation (starts with N or m/n depending on network)
48
- const validPrefixes = this.network === 'xna' ? ['N'] : ['m', 'n'];
49
- if (!validPrefixes.some(prefix => address.startsWith(prefix))) {
50
- throw new InvalidAddressError(
51
- `addresses[${index}] has invalid prefix for network ${this.network}`,
52
- address
53
- );
54
- }
51
+ // Address prefix validation is left to the node (varies by network)
55
52
  });
56
53
 
57
54
  return true;
@@ -69,7 +66,6 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
69
66
  const {
70
67
  qualifierName,
71
68
  addresses: targetAddresses,
72
- assetData = ''
73
69
  } = this.params;
74
70
 
75
71
  // 2. Check if qualifier exists
@@ -85,19 +81,18 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
85
81
  const addresses = await this._getAddresses();
86
82
  const changeAddress = await this.getChangeAddress();
87
83
 
88
- // 4. Find qualifier's owner token (CRITICAL: must have this)
89
- const ownerTokenName = AssetNameParser.getOwnerTokenName(qualifierName);
90
- let ownerTokenUTXO;
84
+ // 4. Find qualifier asset balance (CRITICAL: must have this)
85
+ let qualifierUTXOs;
86
+ let qualifierQuantity;
91
87
  try {
92
- ownerTokenUTXO = await this.ownerTokenManager.findOwnerTokenUTXO(
93
- ownerTokenName,
94
- addresses
95
- );
88
+ const selection = await this.utxoSelector.selectAssetUTXOs(addresses, qualifierName, 1);
89
+ qualifierUTXOs = selection.utxos;
90
+ qualifierQuantity = selection.totalAmount;
96
91
  } catch (error) {
97
- if (error instanceof OwnerTokenNotFoundError) {
92
+ if (error.name === 'InsufficientFundsError') {
98
93
  throw new OwnerTokenNotFoundError(
99
- `You must own the qualifier's owner token (${ownerTokenName}) to tag/untag addresses.`,
100
- ownerTokenName
94
+ `You must own the qualifier asset (${qualifierName}) to tag/untag addresses.`,
95
+ qualifierName
101
96
  );
102
97
  }
103
98
  throw error;
@@ -121,7 +116,7 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
121
116
  const totalXNAInput = utxoSelection.totalXNA;
122
117
 
123
118
  // 9. Recalculate fee with actual input count
124
- const actualInputCount = baseCurrencyUTXOs.length + 1; // +1 for owner token
119
+ const actualInputCount = baseCurrencyUTXOs.length + qualifierUTXOs.length;
125
120
  const actualFee = await this.estimateFee(actualInputCount, 3);
126
121
 
127
122
  // 10. Verify we have enough XNA
@@ -139,7 +134,7 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
139
134
  );
140
135
  const xnaChange = finalTotalInput - burnInfo.amount - actualFee;
141
136
 
142
- // 12. Build inputs (XNA + owner token)
137
+ // 12. Build inputs (XNA + qualifier asset)
143
138
  const inputs = [];
144
139
 
145
140
  // Add XNA inputs
@@ -152,13 +147,14 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
152
147
  });
153
148
  });
154
149
 
155
- // Add owner token input
156
- inputs.push({
157
- txid: ownerTokenUTXO.txid,
158
- vout: ownerTokenUTXO.outputIndex,
159
- address: ownerTokenUTXO.address,
160
- assetName: ownerTokenUTXO.assetName,
161
- satoshis: ownerTokenUTXO.satoshis
150
+ qualifierUTXOs.forEach(utxo => {
151
+ inputs.push({
152
+ txid: utxo.txid,
153
+ vout: utxo.outputIndex,
154
+ address: utxo.address,
155
+ assetName: utxo.assetName,
156
+ satoshis: utxo.satoshis
157
+ });
162
158
  });
163
159
 
164
160
  // 13. Build outputs (ORDER CRITICAL!)
@@ -172,39 +168,30 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
172
168
  outputs.push({ [changeAddress]: parseFloat(xnaChange.toFixed(8)) });
173
169
  }
174
170
 
175
- // Third: Owner token return (CRITICAL - must return or lost forever!)
176
- const ownerTokenReturn = this.ownerTokenManager.createOwnerTokenReturnOutput(
177
- ownerTokenName,
178
- changeAddress
179
- );
180
- outputs.push(ownerTokenReturn);
181
-
182
- // Last: Tag/Untag operation
183
- // Note: Using first address as transaction output address (protocol requirement)
171
+ // Last: Tag/Untag operation. The node creates the qualifier change output
172
+ // from the operation object itself, so this must be sent to the change address.
184
173
  const operationOutput = isUntag
185
174
  ? OutputFormatter.formatUntagAddressesOutput({
186
- tag_name: qualifierName,
187
- addresses: targetAddresses
175
+ qualifier: qualifierName,
176
+ addresses: targetAddresses,
177
+ change_quantity: qualifierQuantity
188
178
  })
189
179
  : OutputFormatter.formatTagAddressesOutput({
190
- tag_name: qualifierName,
180
+ qualifier: qualifierName,
191
181
  addresses: targetAddresses,
192
- asset_data: assetData
182
+ change_quantity: qualifierQuantity
193
183
  });
194
184
 
195
- outputs.push({ [targetAddresses[0]]: operationOutput });
185
+ outputs.push({ [changeAddress]: operationOutput });
196
186
 
197
187
  // 14. Order outputs (protocol requirement)
198
188
  const orderedOutputs = this.outputOrderer.order(outputs);
199
189
 
200
- // 15. Validate owner token is returned (safety check)
201
- this.ownerTokenManager.validateOwnerTokenReturn(inputs, orderedOutputs);
202
-
203
- // 16. Create raw transaction
190
+ // 15. Create raw transaction
204
191
  const rawTx = await this.buildRawTransaction(inputs, orderedOutputs);
205
192
 
206
- // 17. Format and return result
207
- const allUTXOs = [...baseCurrencyUTXOs, ownerTokenUTXO];
193
+ // 16. Format and return result
194
+ const allUTXOs = [...baseCurrencyUTXOs, ...qualifierUTXOs];
208
195
 
209
196
  return this.formatResult(
210
197
  rawTx,
@@ -215,7 +202,7 @@ class TagAddressBuilder extends BaseAssetTransactionBuilder {
215
202
  burnInfo.amount,
216
203
  {
217
204
  qualifierName,
218
- ownerTokenUsed: ownerTokenName,
205
+ qualifierAssetUsed: qualifierName,
219
206
  targetAddresses,
220
207
  addressCount,
221
208
  operationType: isUntag ? 'UNTAG_ADDRESSES' : 'TAG_ADDRESSES'
@@ -13,8 +13,8 @@ const ASSET_COSTS = {
13
13
  ISSUE_RESTRICTED: 3000,
14
14
  REISSUE: 200,
15
15
  REISSUE_RESTRICTED: 200,
16
- TAG_ADDRESS: 0.1, // Per address
17
- UNTAG_ADDRESS: 0.1, // Per address
16
+ TAG_ADDRESS: 0.2, // Per address
17
+ UNTAG_ADDRESS: 0.2, // Per address
18
18
  FREEZE_ADDRESS: 0, // No cost (requires owner token)
19
19
  UNFREEZE_ADDRESS: 0, // No cost (requires owner token)
20
20
  FREEZE_ASSET: 0, // No cost (requires owner token)
@@ -14,7 +14,7 @@ const NETWORKS = {
14
14
  TESTNET: {
15
15
  name: 'xna-test',
16
16
  displayName: 'Neurai Testnet',
17
- addressPrefix: 'm',
17
+ addressPrefix: 't',
18
18
  assetNameMaxLength: 32, // Same as mainnet
19
19
  defaultRPCPort: 19101,
20
20
  coin: 'TXNA'
@@ -96,7 +96,7 @@ function getNetworkConfig(networkName) {
96
96
  function detectNetworkFromAddress(address) {
97
97
  if (address.startsWith('N')) {
98
98
  return 'xna';
99
- } else if (address.startsWith('m') || address.startsWith('n')) {
99
+ } else if (address.startsWith('t')) {
100
100
  return 'xna-test';
101
101
  } else {
102
102
  throw new Error(`Cannot detect network from address: ${address}`);
@@ -128,6 +128,9 @@ class AssetNameParser {
128
128
  if (this.isOwnerToken(assetName)) {
129
129
  return assetName;
130
130
  }
131
+ if (this.isRestricted(assetName)) {
132
+ return assetName.slice(1) + '!';
133
+ }
131
134
  return assetName + '!';
132
135
  }
133
136
 
@@ -52,8 +52,8 @@ class NetworkDetector {
52
52
  return 'xna';
53
53
  }
54
54
 
55
- // Testnet addresses start with 'm' or 'n'
56
- if (address.startsWith(NETWORKS.TESTNET.addressPrefix) || address.startsWith('n')) {
55
+ // Testnet addresses start with 't' (prefix byte 0x7f = 127)
56
+ if (address.startsWith(NETWORKS.TESTNET.addressPrefix)) {
57
57
  return 'xna-test';
58
58
  }
59
59
 
@@ -39,13 +39,18 @@ class OutputFormatter {
39
39
  static formatIssueUniqueOutput(params) {
40
40
  const { root_name, asset_tags, ipfs_hashes } = params;
41
41
 
42
- return {
42
+ const output = {
43
43
  issue_unique: {
44
44
  root_name,
45
- asset_tags,
46
- ipfs_hashes: ipfs_hashes || []
45
+ asset_tags
47
46
  }
48
47
  };
48
+
49
+ if (ipfs_hashes && ipfs_hashes.length > 0) {
50
+ output.issue_unique.ipfs_hashes = ipfs_hashes;
51
+ }
52
+
53
+ return output;
49
54
  }
50
55
 
51
56
  /**
@@ -61,10 +66,11 @@ class OutputFormatter {
61
66
  units,
62
67
  reissuable,
63
68
  has_ipfs,
64
- ipfs_hash
69
+ ipfs_hash,
70
+ owner_change_address
65
71
  } = params;
66
72
 
67
- return {
73
+ const output = {
68
74
  issue_restricted: {
69
75
  asset_name,
70
76
  asset_quantity,
@@ -75,6 +81,12 @@ class OutputFormatter {
75
81
  ipfs_hash: ipfs_hash || ''
76
82
  }
77
83
  };
84
+
85
+ if (owner_change_address) {
86
+ output.issue_restricted.owner_change_address = owner_change_address;
87
+ }
88
+
89
+ return output;
78
90
  }
79
91
 
80
92
  /**
@@ -87,10 +99,12 @@ class OutputFormatter {
87
99
  asset_name,
88
100
  asset_quantity,
89
101
  has_ipfs,
90
- ipfs_hash
102
+ ipfs_hash,
103
+ root_change_address,
104
+ change_quantity
91
105
  } = params;
92
106
 
93
- return {
107
+ const output = {
94
108
  issue_qualifier: {
95
109
  asset_name,
96
110
  asset_quantity,
@@ -98,6 +112,16 @@ class OutputFormatter {
98
112
  ipfs_hash: ipfs_hash || ''
99
113
  }
100
114
  };
115
+
116
+ if (root_change_address) {
117
+ output.issue_qualifier.root_change_address = root_change_address;
118
+ }
119
+
120
+ if (change_quantity !== undefined && change_quantity !== null) {
121
+ output.issue_qualifier.change_quantity = change_quantity;
122
+ }
123
+
124
+ return output;
101
125
  }
102
126
 
103
127
  /**
@@ -110,7 +134,8 @@ class OutputFormatter {
110
134
  asset_name,
111
135
  asset_quantity,
112
136
  reissuable,
113
- new_ipfs
137
+ new_ipfs,
138
+ owner_change_address
114
139
  } = params;
115
140
 
116
141
  const output = {
@@ -129,6 +154,10 @@ class OutputFormatter {
129
154
  output.reissue.ipfs_hash = new_ipfs;
130
155
  }
131
156
 
157
+ if (owner_change_address) {
158
+ output.reissue.owner_change_address = owner_change_address;
159
+ }
160
+
132
161
  return output;
133
162
  }
134
163
 
@@ -144,7 +173,8 @@ class OutputFormatter {
144
173
  change_verifier,
145
174
  new_verifier,
146
175
  reissuable,
147
- new_ipfs
176
+ new_ipfs,
177
+ owner_change_address
148
178
  } = params;
149
179
 
150
180
  const output = {
@@ -167,6 +197,10 @@ class OutputFormatter {
167
197
  output.reissue_restricted.ipfs_hash = new_ipfs;
168
198
  }
169
199
 
200
+ if (owner_change_address) {
201
+ output.reissue_restricted.owner_change_address = owner_change_address;
202
+ }
203
+
170
204
  return output;
171
205
  }
172
206
 
@@ -191,18 +225,23 @@ class OutputFormatter {
191
225
  */
192
226
  static formatTagAddressesOutput(params) {
193
227
  const {
194
- tag_name,
228
+ qualifier,
195
229
  addresses,
196
- asset_data
230
+ change_quantity
197
231
  } = params;
198
232
 
199
- return {
233
+ const output = {
200
234
  tag_addresses: {
201
- tag_name,
202
- addresses,
203
- asset_data: asset_data || ''
235
+ qualifier,
236
+ addresses
204
237
  }
205
238
  };
239
+
240
+ if (change_quantity !== undefined && change_quantity !== null) {
241
+ output.tag_addresses.change_quantity = change_quantity;
242
+ }
243
+
244
+ return output;
206
245
  }
207
246
 
208
247
  /**
@@ -212,16 +251,23 @@ class OutputFormatter {
212
251
  */
213
252
  static formatUntagAddressesOutput(params) {
214
253
  const {
215
- tag_name,
216
- addresses
254
+ qualifier,
255
+ addresses,
256
+ change_quantity
217
257
  } = params;
218
258
 
219
- return {
259
+ const output = {
220
260
  untag_addresses: {
221
- tag_name,
261
+ qualifier,
222
262
  addresses
223
263
  }
224
264
  };
265
+
266
+ if (change_quantity !== undefined && change_quantity !== null) {
267
+ output.untag_addresses.change_quantity = change_quantity;
268
+ }
269
+
270
+ return output;
225
271
  }
226
272
 
227
273
  /**
@@ -120,7 +120,7 @@ describe('AssetNameParser', () => {
120
120
  describe('getOwnerTokenName', () => {
121
121
  it('should add ! to get owner token name', () => {
122
122
  expect(AssetNameParser.getOwnerTokenName('MYTOKEN')).to.equal('MYTOKEN!');
123
- expect(AssetNameParser.getOwnerTokenName('$SECURITY')).to.equal('$SECURITY!');
123
+ expect(AssetNameParser.getOwnerTokenName('$SECURITY')).to.equal('SECURITY!');
124
124
  });
125
125
 
126
126
  it('should return as-is if already an owner token', () => {