From 438371f97a638fb1eec88bbafcc4ba38b178494c Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Sat, 8 Mar 2025 01:29:32 -0500 Subject: [PATCH 1/5] fix(lookup): skip zero addresses when parsing caAlgo field Skip Algorand zero addresses (all bytes are zero) when parsing the verified `caAlgo` field in the `resolve` method. This prevents blank address slots from appearing in the `caAlgo` array when a user unlinks an address from their NFD. Added a reusable `isZeroBytes` utility function to check if a `Uint8Array` contains only zero bytes, which can be used in other parts of the codebase. --- packages/sdk/src/modules/lookup.ts | 7 +++++++ packages/sdk/src/utils/internal/bytes.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/sdk/src/modules/lookup.ts b/packages/sdk/src/modules/lookup.ts index 781e084..c83daab 100644 --- a/packages/sdk/src/modules/lookup.ts +++ b/packages/sdk/src/modules/lookup.ts @@ -1,5 +1,6 @@ import { Address } from 'algosdk' +import { isZeroBytes } from '../utils/internal/bytes' import { determineNfdState, generateMetaTags } from '../utils/internal/nfd' import { parseAddress, parseString, parseUint64 } from '../utils/internal/state' import { isValidName } from '../utils/nfd' @@ -179,6 +180,12 @@ export class LookupModule extends BaseModule { // Split the value into chunks of 32 bytes for (let i = 0; i < value.length; i += PUBLIC_KEY_LENGTH) { const publicKey = value.slice(i, i + PUBLIC_KEY_LENGTH) + + // Skip zero addresses (all bytes are zero) + if (isZeroBytes(publicKey)) { + continue + } + try { const address = new Address(publicKey).toString() if (address) { diff --git a/packages/sdk/src/utils/internal/bytes.ts b/packages/sdk/src/utils/internal/bytes.ts index d4ba228..d202dc5 100644 --- a/packages/sdk/src/utils/internal/bytes.ts +++ b/packages/sdk/src/utils/internal/bytes.ts @@ -27,3 +27,12 @@ export function concatUint8Arrays( return concatenatedArray } + +/** + * Check if a Uint8Array contains only zero bytes + * @param array - The array to check + * @returns True if all bytes are zero, false otherwise + */ +export function isZeroBytes(array: Uint8Array): boolean { + return array.every((byte) => byte === 0) +} From e6d945675838e87f3382a87ae565e03e52b28db5 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Sat, 8 Mar 2025 01:37:28 -0500 Subject: [PATCH 2/5] fix(lookup): add comma-delimited verified.caAlgo string Add support for setting verified Algorand addresses as a comma-delimited string in the `verified.caAlgo` property, in addition to the existing array in the top-level `caAlgo` field. --- packages/sdk/src/modules/lookup.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sdk/src/modules/lookup.ts b/packages/sdk/src/modules/lookup.ts index c83daab..889d8b3 100644 --- a/packages/sdk/src/modules/lookup.ts +++ b/packages/sdk/src/modules/lookup.ts @@ -200,6 +200,11 @@ export class LookupModule extends BaseModule { ) } } + + // Set the verified.caAlgo property as a comma-delimited string + if (caAlgo.length > 0) { + verified.caAlgo = caAlgo.join(',') + } } catch (error) { console.error('Failed to parse Algorand addresses from box:', error) } From c72b69c0dd874c53a50a25bfda3c41787fb4a7bc Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Sat, 8 Mar 2025 02:01:55 -0500 Subject: [PATCH 3/5] fix(manager): fix linking non-owner addresses This fixes a bug where linking non-owner addresses fails because the smart contract requires the address being linked to be the signer for verification. The previous implementation only worked when linking the owner's address, which is special-cased in the contract. - Modify `requireSigner` in base.ts to return the signer for use in methods - Update `linkAddress` and `unlinkAddress` to accept string or Address parameter - Add a signer for the address being linked if it's not the default signer - Update example app to use a dropdown of connected addresses - Improve validation to ensure only connected addresses can be linked - Enhance UI to show available addresses and handle edge cases --- examples/link-address/src/App.tsx | 84 +++++++++++++++++++-------- examples/link-address/src/Connect.tsx | 5 +- packages/sdk/src/modules/base.ts | 7 ++- packages/sdk/src/modules/manager.ts | 76 ++++++++++++------------ 4 files changed, 108 insertions(+), 64 deletions(-) diff --git a/examples/link-address/src/App.tsx b/examples/link-address/src/App.tsx index 02cb6cf..30236e8 100644 --- a/examples/link-address/src/App.tsx +++ b/examples/link-address/src/App.tsx @@ -19,15 +19,19 @@ export function App() { const [isUnlinking, setIsUnlinking] = useState(false) const [linkedAddresses, setLinkedAddresses] = useState([]) - const { activeAddress, transactionSigner } = useWallet() + const { activeAddress, activeWalletAddresses, transactionSigner } = + useWallet() const handleNfdInputChange = (e: React.ChangeEvent) => { setNfdData(null) setNfdName(e.target.value) setLinkedAddresses([]) + setAddressToLink('') } - const handleAddressInputChange = (e: React.ChangeEvent) => { + const handleAddressSelectChange = ( + e: React.ChangeEvent, + ) => { setAddressToLink(e.target.value) } @@ -36,6 +40,7 @@ export function App() { setError('') setNfdData(null) setLinkedAddresses([]) + setAddressToLink('') setIsLoading(true) try { @@ -81,8 +86,8 @@ export function App() { throw new Error('No NFD selected') } - if (!addressToLink.trim()) { - throw new Error('Please enter an address to link') + if (!addressToLink) { + throw new Error('Please select an address to link') } // Check if the address is already linked @@ -95,6 +100,16 @@ export function App() { throw new Error('Only the owner can link addresses to this NFD') } + // Check if the address to link is in the available addresses + if ( + !activeWalletAddresses || + !activeWalletAddresses.includes(addressToLink) + ) { + throw new Error( + 'The address to link must be one of your connected wallet addresses', + ) + } + /** * Link the address to the NFD */ @@ -170,6 +185,10 @@ export function App() { } } + const availableAddresses = activeWalletAddresses + ? activeWalletAddresses.filter((addr) => !linkedAddresses.includes(addr)) + : [] + return (

NFD Address Linker

@@ -260,12 +279,8 @@ export function App() { )}
@@ -278,20 +293,43 @@ export function App() { {nfdData.owner === activeAddress && (

Link New Address

-
- - +
+ {availableAddresses.length > 0 ? ( + + ) : ( +

+ No available addresses to link. +

+ )} +
+ +
)} diff --git a/examples/link-address/src/Connect.tsx b/examples/link-address/src/Connect.tsx index c6e9d7f..cf13821 100644 --- a/examples/link-address/src/Connect.tsx +++ b/examples/link-address/src/Connect.tsx @@ -79,10 +79,11 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { @@ -90,7 +91,7 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { {wallet.activeAccount && (
- {wallet.activeAccount.address} + Active Account: {wallet.activeAccount.address}
)} diff --git a/packages/sdk/src/modules/base.ts b/packages/sdk/src/modules/base.ts index f0507f7..1f6ef14 100644 --- a/packages/sdk/src/modules/base.ts +++ b/packages/sdk/src/modules/base.ts @@ -41,12 +41,15 @@ export abstract class BaseModule { /** * Ensure a signer is set before proceeding + * @returns The current signer * @throws If no signer is set */ - protected requireSigner(): void { - if (!this.getSigner()) { + protected requireSigner(): TransactionSignerAccount { + const signer = this.getSigner() + if (!signer) { throw new Error('Signer required. Call setSigner() first.') } + return signer } /** diff --git a/packages/sdk/src/modules/manager.ts b/packages/sdk/src/modules/manager.ts index 2bd1ebe..6e05b6a 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -1,4 +1,5 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' +import { Address } from 'algosdk' import { parseTransactionError } from '../utils/error-parser' import { strToUint8Array, concatUint8Arrays } from '../utils/internal/bytes' @@ -74,33 +75,38 @@ export class NfdManager extends BaseModule { * @returns The updated NFD * @throws If the address cannot be linked */ - public async linkAddress(address: string): Promise { - this.requireSigner() + public async linkAddress(address: string | Address): Promise { + const signer = this.requireSigner() const nfd = await this.getNfd() - // Ensure the caller is the owner - const signer = this.getSigner() - if (!signer || signer.addr.toString() !== nfd.owner) { + // Ensure the default signer is the owner + if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can link addresses to this NFD') } + // Get the Address to link + const addressToLink = + typeof address === 'string' ? Address.fromString(address) : address + + // If address to link is not the default signer (owner), add a signer for it + if (signer.addr !== addressToLink) { + this.algorand.setSigner(addressToLink, signer.signer) + } + // Get the NFD instance client if (!nfd.appID) { throw new Error('NFD has no application ID') } const nfdAppId = BigInt(nfd.appID) - const nfdInstanceClient = this.getInstanceClient( - nfdAppId, - signer.addr.toString(), - ) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) // Prepare the fields to update const fieldsToUpdate: Uint8Array[] = [ strToUint8Array('u.cav.algo.a'), - signer.addr.publicKey, + addressToLink.publicKey, ] - // Get current box value/size in NFD + // Get current v.caAlgo.0.as box value/size let curCaAlgo: Uint8Array try { curCaAlgo = await nfdInstanceClient.appClient.getBoxValue('v.caAlgo.0.as') @@ -113,7 +119,7 @@ export class NfdManager extends BaseModule { fieldsToUpdate[0], fieldsToUpdate[1], strToUint8Array('v.caAlgo.0.as'), - concatUint8Arrays(curCaAlgo, signer.addr.publicKey), + concatUint8Arrays(curCaAlgo, addressToLink.publicKey), ] // Get the cost of the update @@ -129,7 +135,7 @@ export class NfdManager extends BaseModule { // Add payment transaction for the update cost const updatePaymentTxn = await this.algorand.createTransaction.payment({ - sender: signer.addr.toString(), + sender: signer.addr, receiver: nfdInstanceClient.appAddress, amount: AlgoAmount.MicroAlgos(updateCost), }) @@ -138,12 +144,12 @@ export class NfdManager extends BaseModule { nfdNewGroup.updateFields({ args: { fieldAndVals: fieldsToUpdate } }) // Get the registry client - const registryClient = this.getRegistryClient(signer.addr.toString()) + const registryClient = this.getRegistryClient(signer.addr) // Get the cost to add to registry const regMbrCostResult = await registryClient .newGroup() - .costToAddToAddress({ args: { lookupAddress: address } }) + .costToAddToAddress({ args: { lookupAddress: addressToLink.toString() } }) .simulate({ skipSignatures: true, allowUnnamedResources: true }) const regMbrCost = regMbrCostResult.returns[0] || 0n @@ -151,7 +157,7 @@ export class NfdManager extends BaseModule { // Add payment transaction if needed if (regMbrCost > 0n) { const regPaymentTxn = await this.algorand.createTransaction.payment({ - sender: signer.addr.toString(), + sender: signer.addr, receiver: registryClient.appAddress, amount: AlgoAmount.MicroAlgos(regMbrCost), }) @@ -165,9 +171,9 @@ export class NfdManager extends BaseModule { args: { nfdName: nfd.name, nfdAppId: nfdInstanceClient.appId, - addrToVerify: address, + addrToVerify: addressToLink.toString(), }, - sender: signer.addr, + sender: addressToLink, staticFee: AlgoAmount.MicroAlgos(3000), }) @@ -191,28 +197,28 @@ export class NfdManager extends BaseModule { * @returns The updated NFD * @throws If the address cannot be unlinked */ - public async unlinkAddress(address: string): Promise { - this.requireSigner() + public async unlinkAddress(address: string | Address): Promise { + const signer = this.requireSigner() const nfd = await this.getNfd() - // Ensure the caller is the owner - const signer = this.getSigner() - if (!signer || signer.addr.toString() !== nfd.owner) { + // Ensure the default signer is the owner + if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can unlink addresses from this NFD') } + // Get the Address to unlink + const addressToUnlink = + typeof address === 'string' ? Address.fromString(address) : address + // Get the NFD instance client if (!nfd.appID) { throw new Error('NFD has no application ID') } const nfdAppId = BigInt(nfd.appID) - const nfdInstanceClient = this.getInstanceClient( - nfdAppId, - signer.addr.toString(), - ) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) // Get the registry client - const registryClient = this.getRegistryClient(signer.addr.toString()) + const registryClient = this.getRegistryClient(signer.addr) try { // Create and execute the unlink transaction @@ -222,7 +228,7 @@ export class NfdManager extends BaseModule { args: { nfdName: nfd.name, nfdAppId: nfdInstanceClient.appId, - addrToUnlink: address, + addrToUnlink: addressToUnlink.toString(), }, staticFee: AlgoAmount.MicroAlgos(5000), }) @@ -245,12 +251,11 @@ export class NfdManager extends BaseModule { * @throws If the metadata cannot be set */ public async setMetadata(metadata: Record): Promise { - this.requireSigner() + const signer = this.requireSigner() const nfd = await this.getNfd() - // Ensure the caller is the owner - const signer = this.getSigner() - if (!signer || signer.addr.toString() !== nfd.owner) { + // Ensure the default signer is the owner + if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can set metadata for this NFD') } @@ -259,10 +264,7 @@ export class NfdManager extends BaseModule { throw new Error('NFD has no application ID') } const nfdAppId = BigInt(nfd.appID) - const nfdInstanceClient = this.getInstanceClient( - nfdAppId, - signer.addr.toString(), - ) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) // Convert metadata object to array of fields and values const fieldsAndValues: string[] = [] From 41c8839e810221ecaf88c5ebc6fc4d13536aab47 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Sat, 8 Mar 2025 02:04:03 -0500 Subject: [PATCH 4/5] chore(examples): update Connect component in mint and set-metadata examples Update the `Connect` component in other example projects to match the improvements made to the link-address example. Show full addresses in dropdown and improve the display of the active account. --- examples/mint/src/Connect.tsx | 5 +++-- examples/set-metadata/src/Connect.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/mint/src/Connect.tsx b/examples/mint/src/Connect.tsx index c6e9d7f..cf13821 100644 --- a/examples/mint/src/Connect.tsx +++ b/examples/mint/src/Connect.tsx @@ -79,10 +79,11 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { @@ -90,7 +91,7 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { {wallet.activeAccount && (
- {wallet.activeAccount.address} + Active Account: {wallet.activeAccount.address}
)} diff --git a/examples/set-metadata/src/Connect.tsx b/examples/set-metadata/src/Connect.tsx index c6e9d7f..cf13821 100644 --- a/examples/set-metadata/src/Connect.tsx +++ b/examples/set-metadata/src/Connect.tsx @@ -79,10 +79,11 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { @@ -90,7 +91,7 @@ const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { {wallet.activeAccount && (
- {wallet.activeAccount.address} + Active Account: {wallet.activeAccount.address}
)} From 7c6625e596a086b5cdc8fb71b538cfcbee369ee0 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Sat, 8 Mar 2025 02:48:01 -0500 Subject: [PATCH 5/5] feat(sdk): add setPrimaryAddress method and UI implementation Add functionality to set a specific address as the primary address for an NFD. This determines which address will resolve to the NFD in reverse lookups, giving users control over their primary identity association. The primary address is visually indicated with a blue badge in the UI for easy identification. --- examples/link-address/src/App.tsx | 97 ++++++++++++++++++++++++++--- packages/sdk/src/modules/manager.ts | 52 ++++++++++++++++ 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/examples/link-address/src/App.tsx b/examples/link-address/src/App.tsx index 30236e8..6e81187 100644 --- a/examples/link-address/src/App.tsx +++ b/examples/link-address/src/App.tsx @@ -17,6 +17,7 @@ export function App() { const [isLoading, setIsLoading] = useState(false) const [isLinking, setIsLinking] = useState(false) const [isUnlinking, setIsUnlinking] = useState(false) + const [isSettingPrimary, setIsSettingPrimary] = useState(false) const [linkedAddresses, setLinkedAddresses] = useState([]) const { activeAddress, activeWalletAddresses, transactionSigner } = @@ -185,6 +186,54 @@ export function App() { } } + const handleSetPrimaryAddress = async (address: string) => { + setError('') + setIsSettingPrimary(true) + + try { + if (!activeAddress) { + throw new Error('No active address') + } + + if (!nfdData) { + throw new Error('No NFD selected') + } + + // Check if the active address is the owner + if (nfdData.owner !== activeAddress) { + throw new Error( + 'Only the owner can set the primary address for this NFD', + ) + } + + /** + * Set the address as primary for the NFD + */ + const updatedNfd = await nfd + .setSigner(activeAddress, transactionSigner) + .manage(nfdName) + .setPrimaryAddress(address) + + setNfdData(updatedNfd) + + // Update the linked addresses list + const addresses: string[] = [] + if (updatedNfd.caAlgo && updatedNfd.caAlgo.length > 0) { + updatedNfd.caAlgo.forEach((addr) => { + if (addr) { + addresses.push(addr) + } + }) + } + setLinkedAddresses(addresses) + } catch (err) { + setError(parseTransactionError(err)) + console.error(err) + } finally { + setIsSettingPrimary(false) + } + } + const availableAddresses = activeWalletAddresses ? activeWalletAddresses.filter((addr) => !linkedAddresses.includes(addr)) : [] @@ -264,24 +313,53 @@ export function App() {

No addresses linked to this NFD.

) : (
    - {linkedAddresses.map((address) => ( -
  • + {linkedAddresses.map((address, index) => ( +
  • {address} - {nfdData.owner === activeAddress && ( - + Primary + + )} + {nfdData.owner === activeAddress && ( +
    + {index > 0 && ( + + )} + +
    )}
  • @@ -298,6 +376,7 @@ export function App() { display: 'flex', alignItems: 'center', gap: '10px', + flexWrap: 'wrap', }} > {availableAddresses.length > 0 ? ( diff --git a/packages/sdk/src/modules/manager.ts b/packages/sdk/src/modules/manager.ts index 6e05b6a..0914a28 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -342,4 +342,56 @@ export class NfdManager extends BaseModule { this._nfd = null return this.getNfd() } + + /** + * Set a specific address as the primary address for the NFD + * @param address - The Algorand address to set as primary + * @returns The updated NFD + * @throws If the address cannot be set as primary + */ + public async setPrimaryAddress(address: string | Address): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + // Ensure the default signer is the owner + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can set the primary address for this NFD') + } + + // Get the Address to set as primary + const addressToSet = + typeof address === 'string' ? Address.fromString(address) : address + + // Get the NFD instance client + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + // The only supported field for now is 'v.caAlgo.0.as' + const fieldName = 'v.caAlgo.0.as' + + try { + // Create and execute the setPrimaryAddress transaction + await nfdInstanceClient + .newGroup() + .setPrimaryAddress({ + args: { + fieldName, + address: addressToSet.toString(), + }, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to set primary address: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } }