diff --git a/contracts/javascore/ics20/build.gradle b/contracts/javascore/ics20/build.gradle new file mode 100644 index 000000000..b6254c930 --- /dev/null +++ b/contracts/javascore/ics20/build.gradle @@ -0,0 +1,85 @@ +version = '0.1.0' + +dependencies { + implementation project(':lib') + implementation project(':score-util') + implementation project(':proto-lib') + implementation "com.github.sink772:minimal-json:0.9.6" + testImplementation project(':test-lib') +} + + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.outputLocation = layout.buildDirectory.dir('jacocoHtml') + } +} + +optimizedJar { + dependsOn(project(':lib').jar) + dependsOn(project(':score-util').jar) + dependsOn(project(':proto-lib').jar) + mainClassName = 'ibc.ics20.ICS20Transfer' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } +} + +deployJar { + endpoints { + berlin { + uri = 'https://berlin.net.solidwallet.io/api/v3' + nid = 0x7 + } + lisbon { + uri = 'https://lisbon.net.solidwallet.io/api/v3' + nid = 0x2 + } + local { + uri = 'http://localhost:9082/api/v3' + nid = 0x3 + } + uat { + uri = project.findProperty('uat.host') as String + nid = property('uat.nid') as Integer + to = "$xCallConnection"?:null + } + } + keystore = rootProject.hasProperty('keystoreName') ? "$keystoreName" : '' + password = rootProject.hasProperty('keystorePass') ? "$keystorePass" : '' + parameters { + arg('_ibcHandler', "$ibcCore") + arg('_serializeIrc2', "") + } +} + +task integrationTest(type: Test) { + useJUnitPlatform() + + rootProject.allprojects { + if (it.getTasks().findByName('optimizedJar')) { + dependsOn(it.getTasks().getByName('optimizedJar')) + } + } + + options { + testLogging.showStandardStreams = true + description = 'Runs integration tests.' + group = 'verification' + + testClassesDirs = sourceSets.intTest.output.classesDirs + classpath = sourceSets.intTest.runtimeClasspath + + systemProperty "java", optimizedJar.outputJarName + } + +} diff --git a/contracts/javascore/ics20/src/main/java/ibc/ics20/ICS20Transfer.java b/contracts/javascore/ics20/src/main/java/ibc/ics20/ICS20Transfer.java new file mode 100644 index 000000000..5363f34d1 --- /dev/null +++ b/contracts/javascore/ics20/src/main/java/ibc/ics20/ICS20Transfer.java @@ -0,0 +1,508 @@ +package ibc.ics20; + +import com.eclipsesource.json.Json; +import com.eclipsesource.json.JsonObject; +import com.eclipsesource.json.JsonValue; + +import icon.proto.core.channel.Channel; +import icon.proto.core.channel.Packet; +import icon.proto.core.client.Height; +import ics20.ICS20Lib; +import score.Address; +import score.Context; +import score.DictDB; +import score.VarDB; +import score.annotation.External; +import score.annotation.Optional; +import score.annotation.Payable; +import icon.ibc.interfaces.IIBCModule; + + +import java.math.BigInteger; + +public class ICS20Transfer implements IIBCModule { + public static final String TAG = "ICS20"; + public static final String ICS20_VERSION = "ics20-1"; + + private final DictDB destinationPort = Context.newDictDB("destinationPort", String.class); + private final DictDB destinationChannel = Context.newDictDB("destinationChannel", String.class); + + private final VarDB
ibcHandler = Context.newVarDB("ibcHandler", Address.class); + private final DictDB tokenContracts = Context.newDictDB("tokenContracts", Address.class); + private final VarDB
admin = Context.newVarDB("admin", Address.class); + + public final byte[] serializedIrc2; + + public ICS20Transfer(Address _ibcHandler, byte[] _serializeIrc2) { + if (ibcHandler.get() == null) { + ibcHandler.set(_ibcHandler); + admin.set(Context.getCaller()); + } + serializedIrc2 = _serializeIrc2; + } + + /** + * Set the admin address and ensure only admin can call this function. + * + * @param _admin the new admin address + * @return void + */ + @External + public void setAdmin(Address _admin) { + onlyAdmin(); + admin.set(_admin); + } + + /** + * Retrieves the admin address. + * + * @return the admin address + */ + @External(readonly = true) + public Address getAdmin() { + return admin.get(); + } + + /** + * Retrieves the IBC handler address. + * + * @return the IBC handler address + */ + @External(readonly = true) + public Address getIBCAddress() { + return ibcHandler.get(); + } + + /** + * Retrieves the destination port for the given channel ID. + * + * @param channelId the source channel id + * @return the destination port associated with the channel ID + */ + @External(readonly = true) + public String getDestinationPort(String channelId) { + return destinationPort.get(channelId); + } + + /** + * Retrieves the destination channel for the given channel ID. + * + * @param channelId the source channel id + * @return the destination channel associated with the channel ID + */ + @External(readonly = true) + public String getDestinationChannel(String channelId) { + return destinationChannel.get(channelId); + } + + /** + * Retrieves the token contract address for the given denom. + * + * @param denom the token denom + * @return the token contract address + */ + @External(readonly = true) + public Address getTokenContractAddress(String denom) { + Context.require(tokenContracts.get(denom) != null, TAG + " : Token not registered"); + return tokenContracts.get(denom); + } + + /** + * Register a token contract for cosmos chain. + * + * @param name + * @param symbol + * @param decimals + */ + @External + public void registerCosmosToken(String name, String symbol, int decimals) { + onlyAdmin(); + Address tokenAddress = Context.deploy(serializedIrc2, name, symbol, decimals); + tokenContracts.set(name, tokenAddress); + } + + /** + * Register a token contract for icon chain. + * + * @param tokenAddress the irc2 token contract address + */ + @External + public void registerIconToken(Address tokenAddress) { + onlyAdmin(); + tokenContracts.set(tokenAddress.toString(), tokenAddress); + } + + /** + * Fallback function for token transfer. + * + * @param from Sender address + * @param value Amount + * @param _data Data in json bytes in format of + * { + * "method": "sendFungibleTokens", + * "params": { + * "denomination": "string", + * "amount": "uint64", + * "sender": "string", + * "receiver": "string", + * "sourcePort": "string", + * "sourceChannel": "string", + * "timeoutHeight": { + * "latestHeight": "uint64", + * "revisionNumber": "uint64", + * }, + * "timeoutTimestamp": "uint64", + * "memo":"string" + * } + * } + * + */ + @External + public void tokenFallback(Address from, BigInteger value, byte[] _data) { + String method = ""; + JsonValue params = null; + + try { + String data = new String(_data); + JsonObject json = Json.parse(data).asObject(); + + method = json.get("method").asString(); + params = json.get("params"); + } catch (Exception e) { + Context.revert(TAG + " Invalid data: " + _data.toString()); + } + + if (method.equals("sendFungibleTokens")) { + JsonObject fungibleToken = params.asObject(); + String denomination = fungibleToken.getString("denomination", ""); + BigInteger amount = BigInteger.valueOf(fungibleToken.getLong("amount", 0)); + String sender = fungibleToken.getString("sender", ""); + String receiver = fungibleToken.getString("receiver", ""); + String sourcePort = fungibleToken.getString("sourcePort", ""); + String sourceChannel = fungibleToken.getString("sourceChannel", ""); + BigInteger timeoutTimestamp = BigInteger.valueOf(fungibleToken.getLong("timeoutTimestamp", 0)); + String memo = fungibleToken.getString("memo", ""); + + JsonObject timeoutHeight = fungibleToken.get("timeoutHeight").asObject(); + Height height = new Height(); + height.setRevisionNumber(BigInteger.valueOf(timeoutHeight.getLong("revisionNumber", 0))); + height.setRevisionHeight(BigInteger.valueOf(timeoutHeight.getLong("latestHeight", 0))); + + Context.require(amount.equals(value), TAG + " : Mismatched amount"); + Context.require(sender.equals(from.toString()), TAG + " : Sender address mismatched"); + Context.require(tokenContracts.get(denomination) == Context.getCaller(), + TAG + " : Sender Token Contract not registered"); + + sendFungibleToken(denomination, amount, sender, receiver, sourcePort, sourceChannel, height, + timeoutTimestamp, memo); + } else { + Context.revert(TAG + " : Unknown method"); + } + + } + + /** + * Sends ICX to the specified receiver via the specified channel and port. + * + * @param receiver the cross chain address of the receiver + * @param sourcePort the source port + * @param sourceChannel the source channel + * @param timeoutHeight the timeout height + * @param timeoutTimestamp the timeout timestamp + * @param memo an optional memo + */ + @Payable + @External + public void sendICX(String receiver, String sourcePort, String sourceChannel, Height timeoutHeight, + BigInteger timeoutTimestamp, @Optional String memo) { + Context.require(Context.getValue().compareTo(BigInteger.ZERO) > 0, + TAG + " : ICX amount should be greater than 0"); + + sendFungibleToken("icx", Context.getValue(), Context.getCaller().toString(), receiver, sourcePort, + sourceChannel, timeoutHeight, timeoutTimestamp, memo); + + } + + /** + * Sends a irc2 token from the sender to the receiver. + * + * @param denomination the denomination of the token to send + * @param amount the amount of the token to send + * @param sender the address of the sender + * @param receiver the cross chain address of the receiver + * @param sourcePort the source port + * @param sourceChannel the source channel + * @param timeoutHeight the timeout height(latest height and revision number) + * @param timeoutTimestamp the timeout timestamp + * @param memo an optional memo for the transaction + */ + private void sendFungibleToken(String denomination, BigInteger amount, String sender, String receiver, + String sourcePort, String sourceChannel, Height timeoutHeight, BigInteger timeoutTimestamp, + @Optional String memo) { + String denomPrefix = getDenomPrefix(sourcePort, sourceChannel); + boolean isSource = !denomination.startsWith(denomPrefix); + + if (!isSource) { + Address tokenContractAddress = getTokenContractAddress(denomination); + Context.call(tokenContractAddress, "burn", amount); + } + + byte[] data = ICS20Lib.marshalFungibleTokenPacketData(denomination, amount, sender, receiver, memo); + + String destPort = destinationPort.get(sourceChannel); + String destChannel = destinationChannel.get(sourceChannel); + + if (destChannel == null || destPort == null) { + Context.revert(TAG + " : Connection not properly Configured"); + } + + BigInteger seq = Context.call(BigInteger.class, ibcHandler.get(), "getNextSequenceSend", sourcePort, + sourceChannel); + + Packet newPacket = new Packet(); + + newPacket.setSequence(seq); + newPacket.setSourcePort(sourcePort); + newPacket.setSourceChannel(sourceChannel); + newPacket.setDestinationPort(destPort); + newPacket.setDestinationChannel(destChannel); + newPacket.setTimeoutHeight(timeoutHeight); + newPacket.setTimeoutTimestamp(timeoutTimestamp); + newPacket.setData(data); + + Context.call(ibcHandler.get(), "sendPacket", newPacket.encode()); + } + + /** + * Handles the reception of a packet + * + * @param packet the byte array representation of the packet to be processed + * @param relayer the address of the relayer + * @return a byte array representing the acknowledgement of the packet + * processing + */ + @External + public byte[] onRecvPacket(byte[] packet, Address relayer) { + onlyIBC(); + Packet packetDb = Packet.decode(packet); + ICS20Lib.FungibleTokenPacketData data; + + try { + data = ICS20Lib.unmarshalFungibleTokenPacketData(packetDb.getData()); + Context.require(!data.denom.equals("") && !data.receiver.equals("") && !data.sender.equals("") + && data.amount.compareTo(BigInteger.ZERO) > 0); + } catch (Exception e) { + return ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON; + } + + String denomPrefix = getDenomPrefix(packetDb.getSourcePort(), packetDb.getSourceChannel()); + boolean isSource = data.denom.startsWith(denomPrefix); + + byte[] ack = ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON; + + if (!checkIfReceiverIsAddress(data.receiver)) { + return ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON; + } + + Address receiverAddr = Address.fromString(data.receiver); + + try { + if (isSource) { + String denomOnly = data.denom.substring(denomPrefix.length()); + handleSourceToken(denomOnly, receiverAddr, data.amount, data.memo); + } else { + denomPrefix = getDenomPrefix(packetDb.getDestinationPort(), packetDb.getDestinationChannel()); + String prefixedDenom = denomPrefix + data.denom; + handleDestinationToken(prefixedDenom, receiverAddr, data.amount); + } + } catch (Exception e) { + ack = ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON; + } + + return ack; + } + + /** + * Handles the acknowledgement of a packet. + * + * @param packet the packet being acknowledged + * @param acknowledgement the acknowledgement received + * @param relayer the relayer of the packet + */ + @External + public void onAcknowledgementPacket(byte[] packet, byte[] acknowledgement, Address relayer) { + onlyIBC(); + if (!acknowledgement.equals(ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON)) { + Packet packetDb = Packet.decode(packet); + refundTokens(packetDb); + } + } + + /** + * Handles the timeout of a packet by refunding the tokens associated with the + * packet. + * + * @param packet the encoded packet data + * @param relayer the address of the relayer + */ + @External + public void onTimeoutPacket(byte[] packet, Address relayer) { + Packet packetDb = Packet.decode(packet); + refundTokens(packetDb); + } + + /** + * Refunds tokens based on the provided packet. + * + * @param packet the packet containing the token data + */ + private void refundTokens(Packet packet) { + ICS20Lib.FungibleTokenPacketData data = ICS20Lib.unmarshalFungibleTokenPacketData(packet.getData()); + + String denomPrefix = getDenomPrefix(packet.getSourcePort(), packet.getSourceChannel()); + boolean isSource = !data.denom.startsWith(denomPrefix); + + Address sender = Address.fromString(data.sender); + + if (isSource) { + handleSourceToken(data.denom, sender, data.amount, data.memo); + } else { + handleDestinationToken(data.denom, sender, data.amount); + } + } + + private void handleSourceToken(String denom, Address address, BigInteger amount, String memo) { + if (isNativeAsset(denom)) { + Context.transfer(address, amount); + } else { + Address tokenContractAddress = getTokenContractAddress(denom); + Context.call(tokenContractAddress, "transfer", address, amount, memo.getBytes()); + } + } + + private void handleDestinationToken(String denom, Address address, BigInteger amount) { + Address tokenContractAddress = getTokenContractAddress(denom); + Context.call(tokenContractAddress, "mint", address, amount); + } + + /** + * Initializes the channel opening process. + * + * @param order the order of the channel + * @param connectionHops the connection hops for the channel + * @param portId the port ID for the channel + * @param channelId the channel ID + * @param counterpartyPb the counterparty information + * @param version the version of the channel + */ + @External + public void onChanOpenInit(int order, String[] connectionHops, String portId, String channelId, + byte[] counterpartyPb, String version) { + onlyIBC(); + Context.require(order == Channel.Order.ORDER_UNORDERED, TAG + " : must be unordered"); + Context.require(version.equals(ICS20_VERSION), TAG + " : version should be same with ICS20_VERSION"); + Channel.Counterparty counterparty = Channel.Counterparty.decode(counterpartyPb); + destinationPort.set(channelId, counterparty.getPortId()); + } + + /** + * Channel Opening Process + * + * @param order the order of the channel + * @param connectionHops an array of connection hops + * @param portId the port ID + * @param channelId the channel ID + * @param counterpartyPb the counterparty in protobuf format + * @param version the version + * @param counterPartyVersion the counterparty version + */ + @External + public void onChanOpenTry(int order, String[] connectionHops, String portId, String channelId, + byte[] counterpartyPb, String version, String counterPartyVersion) { + onlyIBC(); + Context.require(order == Channel.Order.ORDER_UNORDERED, TAG + " : must be unordered"); + Context.require(counterPartyVersion.equals(ICS20_VERSION), + TAG + " : version should be same with ICS20_VERSION"); + Channel.Counterparty counterparty = Channel.Counterparty.decode(counterpartyPb); + destinationPort.set(channelId, counterparty.getPortId()); + destinationChannel.set(channelId, counterparty.getChannelId()); + } + + /** + * Handles the acknowledged by the counterparty. + * + * @param portId the identifier of the port on this chain + * @param channelId the identifier of the channel that was opened + * @param counterpartyChannelId the identifier of the channel on the + * counterparty chain + * @param counterPartyVersion the version of the ICS20 protocol used by the + * counterparty + */ + @External + public void onChanOpenAck(String portId, String channelId, String counterpartyChannelId, + String counterPartyVersion) { + onlyIBC(); + Context.require(counterPartyVersion.equals(ICS20_VERSION), + TAG + " : version should be same with ICS20_VERSION"); + destinationChannel.set(channelId, counterpartyChannelId); + } + + /** + * Handles the confirmation of a channel. + * + * @param portId the identifier of the port on this chain + * @param channelId the identifier of the channel that was opened + */ + @External + public void onChanOpenConfirm(String portId, String channelId) { + onlyIBC(); + } + + /** + * Handles the closure of a channel. + * + * @param portId the identifier of the port on this chain + * @param channelId the identifier of the channel that was opened + */ + @External + public void onChanCloseInit(String portId, String channelId) { + Context.revert(TAG + " : Not Allowed"); + } + + /** + * Handles the closing of a channel. + * + * @param portId the identifier of the port on this chain + * @param channelId the identifier of the channel that was opened + */ + @External + public void onChanCloseConfirm(String portId, String channelId) { + onlyIBC(); + } + + private static String getDenomPrefix(String port, String channel) { + return port + "/" + channel + "/"; + } + + private void onlyAdmin() { + Context.require(Context.getCaller().equals(admin.get()), TAG + " : Caller is not admin"); + } + + private void onlyIBC() { + Context.require(Context.getCaller().equals(getIBCAddress()), TAG + " : Caller is not IBC Contract"); + } + + private boolean isNativeAsset(String denom) { + return denom.equals("icx"); + } + + private static boolean checkIfReceiverIsAddress(String receiver) { + try { + Address.fromString(receiver); + return true; + } catch (Exception e) { + return false; + } + } + +} diff --git a/contracts/javascore/ics20/src/test/java/ibc/ics20/ICS20TransferTest.java b/contracts/javascore/ics20/src/test/java/ibc/ics20/ICS20TransferTest.java new file mode 100644 index 000000000..47e6942b9 --- /dev/null +++ b/contracts/javascore/ics20/src/test/java/ibc/ics20/ICS20TransferTest.java @@ -0,0 +1,446 @@ +package ibc.ics20; + +import com.eclipsesource.json.JsonObject; +import com.iconloop.score.test.Account; +import com.iconloop.score.test.Score; +import com.iconloop.score.test.ServiceManager; +import com.iconloop.score.test.TestBase; + +import icon.ibc.interfaces.IIBCHandler; +import icon.ibc.interfaces.IIBCHandlerScoreInterface; +import ibc.icon.test.MockContract; +import icon.proto.core.channel.Channel; +import icon.proto.core.channel.Packet; +import icon.proto.core.client.Height; +import ics20.ICS20Lib; + +import java.lang.String; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import score.Address; +import score.Context; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.function.Executable; +import org.mockito.Mockito; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ICS20TransferTest extends TestBase { + private static final ServiceManager sm = getServiceManager(); + + private static final Account owner = sm.createAccount(); + private static final Account admin = sm.createAccount(); + private static final Account user = sm.createAccount(); + private static final Account sender = sm.createAccount(); + private static final Account relayer = sm.createAccount(); + private static final Address receiver = sm.createAccount().getAddress(); + + private static final Account dest_irc2_token = Account.newScoreAccount(1); + + private static final Account src_irc2_token = Account.newScoreAccount(2); + + // private MockContract token1 = new + // MockContract<>(IRC2ScoreInterface.class, IRC2.class, sm, owner); + + private Score ics20Transfer; + private ICS20Transfer ics20TransferSpy; + private MockContract ibcHandler; + public static final String TAG = "ICS20"; + protected static String port = "transfer"; + protected static int ORDER = Channel.Order.ORDER_UNORDERED; + public static final String ICS20_VERSION = "ics20-1"; + + private final byte[] irc2Bytes = "test".getBytes(); + + @BeforeEach + void setup() throws Exception { + ibcHandler = new MockContract<>(IIBCHandlerScoreInterface.class, IIBCHandler.class, sm, owner); + ics20Transfer = sm.deploy(owner, ICS20Transfer.class, ibcHandler.getAddress(), irc2Bytes); + ics20TransferSpy = (ICS20Transfer) spy(ics20Transfer.getInstance()); + ics20Transfer.setInstance(ics20TransferSpy); + + ics20Transfer.invoke(owner, "setAdmin", admin.getAddress()); + + channelOpenInit("connection-0", "transfer", "channel-0"); + channelOpenAck("channel-0", "channel-1"); + + registerCosmosToken(admin, "transfer/channel-0/dest_irc2_token", "Arch", 18, dest_irc2_token); + ics20Transfer.invoke(admin, "registerIconToken", src_irc2_token.getAddress()); + + } + + @Test + void testGetIBCAddress() { + assertEquals(ibcHandler.getAddress(), ics20Transfer.call("getIBCAddress")); + } + + @Test + void testAdmin() { + assertEquals(admin.getAddress(), ics20Transfer.call("getAdmin")); + + Executable setAdmin = () -> ics20Transfer.invoke(owner, "setAdmin", + owner.getAddress()); + expectErrorMessage(setAdmin, "Reverted(0): ICS20 : Caller is not admin"); + + ics20Transfer.invoke(admin, "setAdmin", owner.getAddress()); + assertEquals(owner.getAddress(), ics20Transfer.call("getAdmin")); + } + + @Test + void testRegisterCosmosToken() { + Executable cosmosToken = () -> ics20Transfer.invoke(user, + "registerCosmosToken", "test", "test", 0); + expectErrorMessage(cosmosToken, "Reverted(0): ICS20 : Caller is not admin"); + + registerCosmosToken(admin, "abc", "ab", 18, dest_irc2_token); + + assertEquals(dest_irc2_token.getAddress(), + ics20Transfer.call("getTokenContractAddress", "abc")); + } + + @Test + void testRegisterIconToken() { + Executable icon = () -> ics20Transfer.invoke(user, "registerIconToken", + src_irc2_token.getAddress()); + expectErrorMessage(icon, "Reverted(0): ICS20 : Caller is not admin"); + + ics20Transfer.invoke(admin, "registerIconToken", + src_irc2_token.getAddress()); + assertEquals(src_irc2_token.getAddress(), + ics20Transfer.call("getTokenContractAddress", + src_irc2_token.getAddress().toString())); + } + + @Test + void testTokenFallbackExceptions() { + byte[] data = "test".getBytes(); + Executable tokenFallback = () -> ics20Transfer.invoke(user, "tokenFallback", + user.getAddress(), BigInteger.ZERO, data); + expectErrorMessage(tokenFallback, "Reverted(0): ICS20 Invalid data: " + + data.toString()); + + byte[] data2 = createByteArray("method", "iconToken", ICX, + sender.getAddress().toString(), admin.getAddress().toString(), "transfer", + "channel-0", BigInteger.ONE, BigInteger.ONE, BigInteger.valueOf(10000), + "memo"); + tokenFallback = () -> ics20Transfer.invoke(user, "tokenFallback", + user.getAddress(), BigInteger.ZERO, data2); + expectErrorMessage(tokenFallback, "Reverted(0): ICS20 : Unknown method"); + + byte[] data3 = createByteArray("sendFungibleTokens", "iconToken", ICX, + sender.getAddress().toString(), admin.getAddress().toString(), "transfer", + "channel-0", BigInteger.ONE, BigInteger.ONE, BigInteger.valueOf(10000), + "memo"); + tokenFallback = () -> ics20Transfer.invoke(user, "tokenFallback", + user.getAddress(), BigInteger.ZERO, data3); + expectErrorMessage(tokenFallback, "Reverted(0): ICS20 : Mismatched amount"); + + byte[] data4 = createByteArray("sendFungibleTokens", "iconToken", ICX, + sender.getAddress().toString(), admin.getAddress().toString(), "transfer", + "channel-0", BigInteger.ONE, BigInteger.ONE, BigInteger.valueOf(10000), + "memo"); + tokenFallback = () -> ics20Transfer.invoke(user, "tokenFallback", + user.getAddress(), ICX, data4); + expectErrorMessage(tokenFallback, "Reverted(0): ICS20 : Sender address mismatched"); + + byte[] data5 = createByteArray("sendFungibleTokens", "iconToken", ICX, + sender.getAddress().toString(), admin.getAddress().toString(), "transfer", + "channel-0", BigInteger.ONE, BigInteger.ONE, BigInteger.valueOf(10000), + "memo"); + tokenFallback = () -> ics20Transfer.invoke(user, "tokenFallback", + sender.getAddress(), ICX, data5); + expectErrorMessage(tokenFallback, "Reverted(0): ICS20 : Sender Token Contract not registered"); + + } + + @Test + void testTokenFallbackSourceToken() { + + byte[] data4 = createByteArray("sendFungibleTokens", + src_irc2_token.getAddress().toString(), ICX, sender.getAddress().toString(), + admin.getAddress().toString(), "transfer", "channel-0", BigInteger.ONE, + BigInteger.ONE, BigInteger.valueOf(10000), "memo"); + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.call(BigInteger.class, ibcHandler.getAddress(), + "getNextSequenceSend", "transfer", + "channel-0")).thenReturn(BigInteger.ONE); + contextMock.when(() -> Context.call(eq(ibcHandler.getAddress()), + eq("sendPacket"), any())).thenReturn(true); + + ics20Transfer.invoke(src_irc2_token, "tokenFallback", sender.getAddress(), + ICX, data4); + } + + } + + @Test + void testTokenFallbackDestToken() { + + byte[] data4 = createByteArray("sendFungibleTokens", + "transfer/channel-0/dest_irc2_token", ICX, sender.getAddress().toString(), + admin.getAddress().toString(), "transfer", "channel-0", BigInteger.ONE, + BigInteger.ONE, BigInteger.valueOf(10000), "memo"); + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.call(BigInteger.class, ibcHandler.getAddress(), + "getNextSequenceSend", "transfer", + "channel-0")).thenReturn(BigInteger.ONE); + contextMock.when(() -> Context.call(eq(ibcHandler.getAddress()), + eq("sendPacket"), any())).thenReturn(true); + contextMock.when(() -> Context.call(dest_irc2_token.getAddress(), "burn", + ICX)).thenReturn(true); + + ics20Transfer.invoke(dest_irc2_token, "tokenFallback", sender.getAddress(), + ICX, data4); + } + + } + + @Test + void testSendICX() { + BigInteger amount = BigInteger.TEN.multiply(ICX); + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.getValue()).thenReturn(amount); + // for the non configured port or channel id + Executable sendICX = () -> ics20Transfer.invoke(sender, "sendICX", + receiver.toString(), "transfer", "channel-1", new Height(), amount, "memo"); + expectErrorMessage(sendICX, "Reverted(0): ICS20 : Connection not properly Configured"); + } + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.getValue()).thenReturn(amount); + contextMock.when(() -> Context.call(BigInteger.class, ibcHandler.getAddress(), + "getNextSequenceSend", "transfer", + "channel-0")).thenReturn(BigInteger.ONE); + contextMock.when(() -> Context.call(eq(ibcHandler.getAddress()), + eq("sendPacket"), any())).thenReturn(true); + + ics20Transfer.invoke(admin, "registerIconToken", + src_irc2_token.getAddress()); + ics20Transfer.invoke(sender, "sendICX", receiver.toString(), "transfer", + "channel-0", new Height(), amount, "memo"); + } + + } + + @Test + void testOnRecvPacket_icx() { + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.transfer(receiver, ICX)).then(invocationOnMock -> null); + _onRecvPacket(ICX, "transfer/channel-1/icx"); + } + } + + @Test + void testOnRecvPacket_source() { + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, Mockito.CALLS_REAL_METHODS)) { + contextMock + .when(() -> Context.call(src_irc2_token.getAddress(), "transfer", receiver, ICX, + "memo".getBytes())) + .thenReturn(true); + + _onRecvPacket(ICX, "transfer/channel-1/" + src_irc2_token.getAddress().toString()); + } + + } + + @Test + void testOnRecvPacket_dest() { + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, Mockito.CALLS_REAL_METHODS)) { + contextMock + .when(() -> Context.call(dest_irc2_token.getAddress(), "mint", receiver, ICX)) + .thenReturn(true); + _onRecvPacket(ICX, "dest_irc2_token"); + } + + } + + @Test + void testOnAcknowledgement_successful() { + + Packet packet = _onRefundPacket(ICX, "src_irc2_token"); + Executable e = () -> ics20Transfer.invoke(admin, "onAcknowledgementPacket", + packet.encode(), ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, + relayer.getAddress()); + expectErrorMessage(e, "Reverted(0): ICS20 : Caller is not IBC Contract"); + + ics20Transfer.invoke(ibcHandler.account, "onAcknowledgementPacket", + packet.encode(), ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON, + relayer.getAddress()); + + } + + @Test + void testOnAcknowledgement_failure_icx() { + + Packet packet = _onRefundPacket(ICX, "icx"); + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.transfer(sender.getAddress(), + ICX)).then(invocationOnMock -> null); + ics20Transfer.invoke(ibcHandler.account, "onAcknowledgementPacket", + packet.encode(), ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, relayer.getAddress()); + } + + } + + @Test + void testOnAcknowledgement_failure_source_token() { + + Packet packet = _onRefundPacket(ICX, src_irc2_token.getAddress().toString()); + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock + .when(() -> Context.call(src_irc2_token.getAddress(), "transfer", sender.getAddress(), ICX, + "memo".getBytes())) + .thenReturn(true); + contextMock.when(() -> Context.call(any(), any(), any())).thenReturn(true); + + ics20Transfer.invoke(ibcHandler.account, "onAcknowledgementPacket", + packet.encode(), ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON, relayer.getAddress()); + } + + } + + @Test + void testOnTimeOutPacket_dest_token() { + + Packet packet = _onRefundPacket(ICX, "transfer/channel-0/dest_irc2_token"); + + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, + Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.call(BigInteger.class, ibcHandler.getAddress(), + "getNextSequenceSend", "transfer", + "channel-0")).thenReturn(BigInteger.ONE); + contextMock.when(() -> Context.call(dest_irc2_token.getAddress(), + "mint", sender.getAddress(), ICX)).thenReturn(true); + + ics20Transfer.invoke(ibcHandler.account, "onTimeoutPacket", packet.encode(), + relayer.getAddress()); + } + } + + void _onRecvPacket(BigInteger amount, String denom) { + String source_channel = "channel-1"; + String dest_channel = "channel-0"; + Packet packet = createPacket(denom, amount, "sender", receiver.toString(), source_channel, dest_channel); + ics20Transfer.invoke(ibcHandler.account, "onRecvPacket", packet.encode(), relayer.getAddress()); + } + + Packet _onRefundPacket(BigInteger amount, String denom) { + String source_channel = "channel-0"; + String dest_channel = "channel-1"; + Packet packet = createPacket(denom, amount, sender.getAddress().toString(), "receiver", source_channel, + dest_channel); + return packet; + } + + private Packet createPacket(String denom, BigInteger amount, String sender, String receiver, String source_channel, + String dest_channel) { + + Height timeOutHeight = new Height(); + timeOutHeight.setRevisionHeight(BigInteger.valueOf(sm.getBlock().getHeight())); + timeOutHeight.setRevisionNumber(BigInteger.ONE); + + String data = "{" + + "\"amount\":\"" + ICX.toString() + "\"," + + "\"denom\":\"" + denom + "\"," + + "\"receiver\":\"" + receiver + "\"," + + "\"sender\":\"" + sender + "\"," + + "\"memo\":\"" + "memo" + "\"" + + "}"; + + Packet packet = new Packet(); + packet.setSequence(BigInteger.ONE); + packet.setSourcePort("transfer"); + packet.setSourceChannel(source_channel); + packet.setDestinationPort("transfer"); + packet.setDestinationChannel(dest_channel); + packet.setTimeoutHeight(timeOutHeight); + packet.setTimeoutTimestamp(BigInteger.valueOf(10000)); + packet.setData(data.getBytes()); + + return packet; + + } + + private void expectErrorMessage(Executable executable, String expectedErrorMessage) { + AssertionError e = assertThrows(AssertionError.class, executable); + assertEquals(expectedErrorMessage, e.getMessage()); + } + + private void registerCosmosToken(Account deployer, String name, String symbol, int decimals, Account token) { + try (MockedStatic contextMock = Mockito.mockStatic(Context.class, Mockito.CALLS_REAL_METHODS)) { + contextMock.when(() -> Context.deploy(irc2Bytes, name, symbol, decimals)).thenReturn(token.getAddress()); + ics20Transfer.invoke(deployer, "registerCosmosToken", name, symbol, decimals); + } + } + + private byte[] createByteArray(String methodName, String denomination, BigInteger amount, String sender, + String receiver, String sourcePort, String sourceChannel, BigInteger latestHeight, + BigInteger revisionNumber, BigInteger timeoutTimestamp, String memo) { + + JsonObject timeoutHeight = new JsonObject() + .add("latestHeight", latestHeight.longValue()) + .add("revisionNumber", revisionNumber.longValue()); + + JsonObject internalParameters = new JsonObject() + .add("denomination", denomination.toString()) + .add("amount", amount.longValue()) + .add("sender", sender.toString()) + .add("receiver", receiver.toString()) + .add("sourcePort", sourcePort.toString()) + .add("sourceChannel", sourceChannel.toString()) + .add("timeoutHeight", timeoutHeight) + .add("timeoutTimestamp", timeoutTimestamp.longValue()) + .add("memo", memo.toString()); + + JsonObject jsonData = new JsonObject() + .add("method", methodName.toString()) + .add("params", internalParameters); + + return jsonData.toString().getBytes(); + } + + public void channelOpenInit(String connectionId, String counterpartyPort, String channelId) { + Channel.Counterparty counterparty = new Channel.Counterparty(); + counterparty.setPortId(counterpartyPort); + counterparty.setChannelId(""); + ics20Transfer.invoke(ibcHandler.account, "onChanOpenInit", ORDER, new String[] { connectionId }, port, + channelId, counterparty.encode(), ICS20_VERSION); + } + + public void channelOpenTry(String connectionId, String counterpartyPort, String channelId, + String counterpartyChannelId) { + Channel.Counterparty counterparty = new Channel.Counterparty(); + counterparty.setPortId(counterpartyPort); + counterparty.setChannelId(counterpartyChannelId); + ics20Transfer.invoke(ibcHandler.account, "onChanOpenTry", ORDER, new String[] { connectionId }, port, channelId, + counterparty.encode(), ICS20_VERSION, ICS20_VERSION); + } + + public void channelOpenAck(String channelId, String counterpartyChannelId) { + ics20Transfer.invoke(ibcHandler.account, "onChanOpenAck", port, channelId, counterpartyChannelId, + ICS20_VERSION); + } + + public void onChanCloseInit(String channelId) { + ics20Transfer.invoke(ibcHandler.account, "onChanCloseInit", port, channelId); + } +} diff --git a/contracts/javascore/lib/src/main/java/ics20/ICS20Lib.java b/contracts/javascore/lib/src/main/java/ics20/ICS20Lib.java new file mode 100644 index 000000000..f7acf6c1b --- /dev/null +++ b/contracts/javascore/lib/src/main/java/ics20/ICS20Lib.java @@ -0,0 +1,90 @@ +package ics20; + +import score.annotation.Optional; +import ibc.icon.score.util.StringUtil; +import java.math.BigInteger; + +public class ICS20Lib { + + public static class FungibleTokenPacketData { + public String denom; + public String sender; + public String receiver; + public BigInteger amount; + public String memo; + } + + public static final byte[] SUCCESSFUL_ACKNOWLEDGEMENT_JSON = "{\"result\":\"AQ==\"}".getBytes(); + public static final byte[] FAILED_ACKNOWLEDGEMENT_JSON = "{\"error\":\"failed\"}".getBytes(); + public static final Integer CHAR_SLASH = 0x2f; + public static final Integer CHAR_BACKSLASH = 0x5c; + public static final Integer CHAR_F = 0x66; + public static final Integer CHAR_R = 0x72; + public static final Integer CHAR_N = 0x6e; + public static final Integer CHAR_B = 0x62; + public static final Integer CHAR_T = 0x74; + public static final Integer CHAR_CLOSING_BRACE = 0x7d; + public static final Integer CHAR_M = 0x6d; + + private static final int CHAR_DOUBLE_QUOTE = '"'; + + static boolean isEscapeNeededString(byte[] bz) { + for (byte b : bz) { + int c = b & 0xFF; + if (c == CHAR_DOUBLE_QUOTE) { + return true; + } + } + return false; + } + + public static byte[] marshalFungibleTokenPacketData(String escapedDenom, BigInteger amount, String escapedSender, String escapedReceiver, @Optional String escapedMemo) { + if (escapedMemo == null) { + escapedMemo = ""; + } + String jsonString = "{" + + "\"amount\":\"" + amount.toString() + "\"," + + "\"denom\":\"" + escapedDenom + "\"," + + "\"receiver\":\"" + escapedReceiver + "\"," + + "\"sender\":\"" + escapedSender + "\"," + + "\"memo\":\"" + escapedMemo + "\"" + + "}"; + + return jsonString.getBytes(); + } + + public static FungibleTokenPacketData unmarshalFungibleTokenPacketData(byte[] packet) { + StringBuilder sanitized = new StringBuilder(); + String jsonString = new String(packet); + + for (char c : jsonString.toCharArray()){ + if (c != '\\' && c != '\"' && c !='{' && c!='}'){ + sanitized.append(c); + } + } + jsonString=sanitized.toString(); + + String[] jsonParts = StringUtil.split(jsonString, ','); + + FungibleTokenPacketData data = new FungibleTokenPacketData(); + + data.amount = new BigInteger(getValue(jsonParts[0])); + + data.denom = getValue(jsonParts[1]); + data.receiver = getValue(jsonParts[2]); + data.sender = getValue(jsonParts[3]); + if (jsonParts.length > 4) { + data.memo = getValue(jsonParts[4]); + } else { + data.memo = ""; + } + + return data; + } + + private static String getValue(String keyValue) { + return StringUtil.split(keyValue, ':')[1].trim(); + + } + +} diff --git a/contracts/javascore/score-util/src/main/java/ibc/icon/score/util/StringUtil.java b/contracts/javascore/score-util/src/main/java/ibc/icon/score/util/StringUtil.java index 99bcb7bfa..cf65901b3 100644 --- a/contracts/javascore/score-util/src/main/java/ibc/icon/score/util/StringUtil.java +++ b/contracts/javascore/score-util/src/main/java/ibc/icon/score/util/StringUtil.java @@ -125,5 +125,31 @@ public static byte[] encodePacked(Object... params) { } return result.toString().getBytes(); } - + + public static String[] split(String input, char delimiter) { + if (input == null || input.isEmpty()) { + return new String[0]; + } + + List substrings = new ArrayList<>(); + int startIndex = 0; + int delimiterIndex; + + while ((delimiterIndex = input.indexOf(delimiter, startIndex)) != -1) { + String substring = input.substring(startIndex, delimiterIndex); + substrings.add(substring); + + startIndex = delimiterIndex + 1; + } + + String lastSubstring = input.substring(startIndex); + substrings.add(lastSubstring); + + int size = substrings.size(); + String[] result = new String[size]; + for (int i = 0; i < size; i++) { + result[i] = substrings.get(i); + } + return result; + } } \ No newline at end of file diff --git a/contracts/javascore/settings.gradle b/contracts/javascore/settings.gradle index beb9a637f..98896fff7 100644 --- a/contracts/javascore/settings.gradle +++ b/contracts/javascore/settings.gradle @@ -7,6 +7,7 @@ include( 'test-lib', 'ibc', 'xcall-connection', + 'ics20' ) include(':tendermint')