Skip to content

Conversation

Tomas-Silva2187
Copy link
Contributor

@Tomas-Silva2187 Tomas-Silva2187 commented Aug 6, 2025

feat(satp): add support for NFTs

Implements support for cross-chain transfers of NFTs with Ethereum and Besu Leafs.

CLOSES #3869
Pull Request Requirements

  • Rebased onto upstream/main branch and squashed into single commit to help maintainers review it more efficient and to avoid spaghetti git commit graphs that obfuscate which commit did exactly what change, when and, why.
  • Have git sign off at the end of commit message to avoid being marked red. You can add -s flag when using git commit command. You may refer to this link for more information.
  • Follow the Commit Linting specification. You may refer to this link for more information.

Character Limit

  • Pull Request Title and Commit Subject must not exceed 72 characters (including spaces and special characters).
  • Commit Message per line must not exceed 80 characters (including spaces and special characters).

A Must Read for Beginners
For rebasing and squashing, here's a must read guide for beginners.

@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch 2 times, most recently from 2ba814c to 52ac5ad Compare August 7, 2025 09:09
Comment on lines 165 to 177
TokenType tt = tokens[tokenId].tokenType;
if(tt == TokenType.NONSTANDARD_FUNGIBLE || tt == TokenType.ERC20) {
if(tokens[tokenId].amount > 0) {
revert TokenLocked(tokenId);
}
}
else if(tt == TokenType.NONSTANDARD_NONFUNGIBLE || tt == TokenType.ERC721) {
if(tokens[tokenId].amount != 0) {
revert TokenLocked(tokenId);
}
}
else {
revert TokenTypeNotSupported(tokenId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be fungible and not fungible? Will the other standards have different APIs?

And for the contracts that are both fungible and not fungible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokensType were reduced, to keep track with the TokenTypes used by SATP. Also, the ERC standards were separated as another independent element that composes an asset

Copy link
Contributor

@LordKubaya LordKubaya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at the comments.

Comment on lines 185 to 218
if(lockSuccess) {
// The locked amount is added to the amount of the token struct
tokens[tokenId].amount += amount;
emit Lock(tokenId, amount);
TokenType tt = tokens[tokenId].tokenType;
if (tt == TokenType.ERC20 || tt == TokenType.NONSTANDARD_FUNGIBLE) {
// The locked amount is added to the amount of the token struct
tokens[tokenId].amount += assetAttribute;
}
else if (tt == TokenType.ERC721 || tt == TokenType.NONSTANDARD_NONFUNGIBLE) {
// When dealing with non-fungible tokens, the "amount" is interpreted as the token unique descriptor.
tokens[tokenId].amount = assetAttribute;
}
emit Lock(tokenId, assetAttribute);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is not of those types, what happens?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ do error handling

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


if (token.amount == undefined) {
throw new AmountMissingError(fnTag);
let token: FungibleAsset | NonFungibleAsset;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't you abstract this with a class name asset, and the fungible and non fungible are extensions of the same?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be probably the right thing to do, because in the future it will be easier to add new features. @Tomas-Silva2187, @LordKubaya, I propose a middle term, which is leaving this solution (provided it works), and @Tomas-Silva2187 creates a detailed issue with a proposal to implement what @LordKubaya proposed.

Copy link
Contributor Author

@Tomas-Silva2187 Tomas-Silva2187 Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done as @LordKubaya suggested

Comment on lines 32 to 51
switch (asset.type) {
case TokenType.ERC20:
case TokenType.NONSTANDARD_FUNGIBLE:
protoAsset.amount = BigInt((asset as EvmFungibleAsset).amount);
protoAsset.contractAddress = (
asset as EvmFungibleAsset
).contractAddress;
break;
case TokenType.ERC721:
case TokenType.NONSTANDARD_NONFUNGIBLE:
protoAsset.amount = BigInt(
(asset as EvmNonFungibleAsset).uniqueDescriptor,
);
protoAsset.contractAddress = (
asset as EvmNonFungibleAsset
).contractAddress;
break;
default:
throw new Error(`Unsupported asset type ${asset.type}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see these lines repeated multiple times. Maybe create a method to reduce redundancy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return this.wrapperContractName;
case "NONFUNGIBLE":
//TODO implement
//TODO implement: can be the same wrapper of fungible assets
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why leave this comment? Isn't this PR the implementation of that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR only implements NFT support for Ethereum and Besu. A new issue was created for Fabric support to be later implemented.

Comment on lines 1156 to 1178
//Uncomment following snippet to allow non fungible tokens to be returned
/*return {
type: Number(token.tokenType),
id: token.tokenId,
referenceId: token.referenceId,
owner: token.owner,
mspId: token.mspId,
channelName: token.channelName,
contractName: token.contractName,
uniqueDescriptor: token.amount.toString(),
network: this.networkIdentification,
} as FabricNonFungibleAsset;*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these lines commented?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleared

Comment on lines 41 to 52
/*function testApprove() public {
uint256 tokenId = 1001;
vm.prank(bridge);
satpContract.mint(user, tokenId);
vm.startPrank(user);
satpContract.approveAsset(bridge, tokenId);
vm.stopPrank();
vm.prank(bridge);
bool allowance = satpContract.checkAssignment(bridge, tokenId);
assertEq(allowance, true, "Approval allowance mismatch");
}*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this test commented

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was corrected, and is now working

Comment on lines 119 to 124
//function testMint() public {
//wrapperContract.wrap(contract1.name(), address(contract1), TokenType.NONSTANDARD_FUNGIBLE, contract1.name(), "refID", address(this), signatures);
//wrapperContract.mint(contract1.name(), 10);

//assertEq(contract1.balanceOf(address(this)), 10, "Token not minted");
//}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this test commented?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was corrected and is now working

bytecode: contract.bytecode.object,
},
keychainId: this.keychainPlugin1.getKeychainId(),
keychainId: this.keychainPluginFungible.getKeychainId(), // TODO: Should we use a different keyChainId for the oracle?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think we should?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment was due to a misunderstanding. Comment was cleared, and this keychain can still be used for both fungible contracts and oracle contracts

},
[
{
assetType: SupportedBesuContractTypes.FUNGIBLE,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this marked as an assetType fungible? Is this even an asset?
Can you fix this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. ORACLE was defined as a SupportedContractType, to allow for a more logical support of this change to the test environments.

@RafaelAPB RafaelAPB self-assigned this Aug 7, 2025
error InsuficientAmountLocked(string tokenId, uint256 amount);

error TokenTypeNotSupported(string tokenId);

Copy link
Contributor

@RafaelAPB RafaelAPB Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly a better name is TokenNotSupported, because we only know what types we support.

Suggested change
error TokenNotSupported(string tokenId);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if(tokens[tokenId].amount > 0) {
revert TokenLocked(tokenId);
TokenType tt = tokens[tokenId].tokenType;
if(tt == TokenType.NONSTANDARD_FUNGIBLE || tt == TokenType.ERC20) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean by non standard fungible?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We support erc721 (non fungible) tokens and erc20 (fungible). we should probably refer to the specific standards that we support

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe in this situation, tokens that do not follow the ERC APIs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokenTypes were reduced to Nonstandard Fungible and Nonstandard NonFungible. They kept this nomenclature due to an error in CBDC that occurs when these names are changed. As for the token standards, they were defined as new attribute of assets, independent of the TokenType.

Copy link
Contributor

@RafaelAPB RafaelAPB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for your contribution. Overall, this is an impressive contribution. Code is clean, tests are detailed and address the implemented functionality. Please do add documentation on your added feature - namely the "assetAttribute". It is understandable that this parameter is a generalization of the old "amount", but the different supported values should be well-documented.

Prior to merge, please address my and Carlo's comments - we can use the PR threads to discuss.

Great work!

if(tokens[tokenId].amount > 0) {
revert TokenLocked(tokenId);
TokenType tt = tokens[tokenId].tokenType;
if(tt == TokenType.NONSTANDARD_FUNGIBLE || tt == TokenType.ERC20) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We support erc721 (non fungible) tokens and erc20 (fungible). we should probably refer to the specific standards that we support

Comment on lines 185 to 218
if(lockSuccess) {
// The locked amount is added to the amount of the token struct
tokens[tokenId].amount += amount;
emit Lock(tokenId, amount);
TokenType tt = tokens[tokenId].tokenType;
if (tt == TokenType.ERC20 || tt == TokenType.NONSTANDARD_FUNGIBLE) {
// The locked amount is added to the amount of the token struct
tokens[tokenId].amount += assetAttribute;
}
else if (tt == TokenType.ERC721 || tt == TokenType.NONSTANDARD_NONFUNGIBLE) {
// When dealing with non-fungible tokens, the "amount" is interpreted as the token unique descriptor.
tokens[tokenId].amount = assetAttribute;
}
emit Lock(tokenId, assetAttribute);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ do error handling

// upon minting, the minted attribute is set as the value that was minted
// (may it be amount for fungibles, or uniqueDescriptor for non-fungibles)
tokens[tokenId].amount = assetAttribute;
emit Mint(tokenId, assetAttribute);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this asset attribute work, in particular? Please add documentation on what is the goal of this attribute and how we use it for different standards

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @param assetAttribute The asset attribute of tokens to be encoded.
*/
function encodeParams(VarType[] memory variables, string memory tokenId, address receiver, uint256 amount) internal view returns (bytes[] memory){
function encodeParams(VarType[] memory variables, string memory tokenId, address receiver, uint256 assetAttribute) internal view returns (bytes[] memory){
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function should have a more specific name since it is not general-purpose encoding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It was renamed as AssetParameterIdentifierEncoder, as it encodes custom parameters defined for the assets, to use them in contract calls.


if (token.amount == undefined) {
throw new AmountMissingError(fnTag);
let token: FungibleAsset | NonFungibleAsset;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be probably the right thing to do, because in the future it will be easier to add new features. @Tomas-Silva2187, @LordKubaya, I propose a middle term, which is leaving this solution (provided it works), and @Tomas-Silva2187 creates a detailed issue with a proposal to implement what @LordKubaya proposed.

throw new WrapperContractError(
`${fnTag}, Wrapper Contract not deployed`,
);
switch (asset.type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comments as in besu-leaf

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been addressed

network: NetworkId;
}

export type Brand<K, T> = K & { __brand: T };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice solution

export interface EvmFungibleAsset extends EvmAsset, FungibleAsset {}
export interface EvmNonFungibleAsset extends EvmAsset, NonFungibleAsset {}

export enum VarType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps assetParameterIdentifier would be a more accurate name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


const proof = await this.bridgeEndPoint.getProof(
asset,
this.claimType,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. However there is quite a lot of code duplication. Could you extract the functionality into a function that can be reused in these different related functionalities?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

/**
* @title SATPTokenContract
* The SATPTokenContract is an example of a custom ERC721 token contract.
* This is a simple contract, meaning it uses functions that assume everything is correct.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean that functions "assume everything is correct"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially, the NFT contract was an unsafe OpenZeppelin version, which used functions that did not pre-check if the function caller had authorization over the asset it was interacting with. The currently implemented version uses safe functions, and the approve action, which is required to deal with these aspects of authorization, is implemented.

@RafaelAPB RafaelAPB force-pushed the satp-stg branch 2 times, most recently from 4cacbaf to 44f3bb4 Compare August 20, 2025 15:52
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch from c548d20 to a03c7b0 Compare August 25, 2025 16:40
@RafaelAPB RafaelAPB force-pushed the satp-stg branch 7 times, most recently from 6617318 to 83f9482 Compare August 27, 2025 13:31
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch from 1cb5caf to 392aa6e Compare August 28, 2025 22:09
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch 2 times, most recently from f14db8d to 45e1e33 Compare August 31, 2025 17:02
@RafaelAPB RafaelAPB force-pushed the satp-stg branch 6 times, most recently from 88864ef to e7f952b Compare September 18, 2025 20:36
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch from 59d6d7d to b830efc Compare September 20, 2025 21:21
@RafaelAPB RafaelAPB force-pushed the satp-stg branch 2 times, most recently from 724038e to bd7f382 Compare September 22, 2025 14:23
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch from b830efc to 78bc4c6 Compare September 22, 2025 14:29
@RafaelAPB RafaelAPB force-pushed the satp-stg branch 15 times, most recently from b3bb285 to a14f896 Compare September 29, 2025 04:43
@Tomas-Silva2187 Tomas-Silva2187 force-pushed the REDOnftSupportDev25-08-04 branch from 78bc4c6 to 26c13ed Compare September 29, 2025 10:44
@RafaelAPB
Copy link
Contributor

@Tomas-Silva2187 most items seem to have been addressed. Could you please confirm that you addressed all comments (I see some items without a response). Thank you!

@RafaelAPB RafaelAPB force-pushed the satp-stg branch 2 times, most recently from 9bee4bd to 1ae6a34 Compare October 1, 2025 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants