diff --git a/contracts/javascore/gradle.properties b/contracts/javascore/gradle.properties index 85bed45f4..b8e2f8352 100644 --- a/contracts/javascore/gradle.properties +++ b/contracts/javascore/gradle.properties @@ -26,6 +26,8 @@ uat.contracts.mock-dapp=cx6a52b5f7e24c2bcea7758341dcb599f18dd03ecb uat.contracts.xcall=cx1edf8867740f89866c6f92c50b707f2393d8fe12 uat.contracts.xcall-connection=cx5558aac127561b237dbf78845ad50e8bdba2a6b2 uat.contracts.xcall-multi-protocol=cxe31140b7cc953779fe33681020e08f9d0d158757 +# uat.contracts.ics20-app=cxc44cfdfae14730bef8b8679b4409dee3cf5fbbb6 + #LOCAL local.contracts.ibc-core=hxb6b5791be0b5ef67063b3c10b840fb81514db2fd diff --git a/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCClient.java b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCClient.java index 354190bb1..c541f5629 100644 --- a/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCClient.java +++ b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCClient.java @@ -18,7 +18,7 @@ public interface IIBCClient { /** * {@code @dev} registerClient registers a new client type into the client registry * @param clientType Type of client - * @param lightClient Light client contract address + * @param client Light client contract address */ void registerClient(String clientType, Address client); diff --git a/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCModule.java b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCModule.java index ebf6ef794..91133586c 100644 --- a/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCModule.java +++ b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IIBCModule.java @@ -1,6 +1,7 @@ package ibc.icon.interfaces; import foundation.icon.score.client.ScoreInterface; +import icon.proto.core.channel.*; import score.Address; // IIBCModule defines an interface that implements all the callbacks diff --git a/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IICS20Bank.java b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IICS20Bank.java new file mode 100644 index 000000000..43cf617a4 --- /dev/null +++ b/contracts/javascore/lib/src/main/java/ibc/icon/interfaces/IICS20Bank.java @@ -0,0 +1,41 @@ +package ibc.icon.interfaces; + +import foundation.icon.score.client.ScoreInterface; +import score.Address; + +import java.math.BigInteger; + +@ScoreInterface +public interface IICS20Bank { + /** + * balanceOf returns the balance of the given account on the specified denom. + */ + void balanceOf(Address account, String denom); + + /** + * transferFrom transfers amount of denom from sender to recipient. + */ + void transferFrom(Address from, Address to, String denom, BigInteger amount); + + /** + * mint creates amount of denom and adds it to the balance of the given account. + */ + void mint(Address account, String denom, BigInteger amount); + + /** + * burn subtracts amount of denom from the balance of the given account. + */ + void burn(Address account, String denom, BigInteger amount); + + /** + * deposit transfers amount of tokenContract from caller to the bank and mints the same amount of tokenContract to the caller. + */ + void deposit(Address tokenContract, BigInteger amount, Address receiver); + + /** + * withdraw transfers amount of tokenContract from the bank to receiver and burns the same amount of tokenContract from the bank. + */ + void withdraw(Address tokenContract, BigInteger amount, Address receiver); + + +} diff --git a/contracts/javascore/lib/src/main/java/ibc/icon/structs/messages/MsgOnChanOpenInit.java b/contracts/javascore/lib/src/main/java/ibc/icon/structs/messages/MsgOnChanOpenInit.java new file mode 100644 index 000000000..393381542 --- /dev/null +++ b/contracts/javascore/lib/src/main/java/ibc/icon/structs/messages/MsgOnChanOpenInit.java @@ -0,0 +1,50 @@ +package ibc.icon.structs.messages; + +import icon.proto.core.channel.Channel.Counterparty; +public class MsgOnChanOpenInit { + private String[] connectionHops; + private String portId; + private String channelId; + private Counterparty counterParty; + private String version; + + public String[] getConnectionHops() { + return connectionHops; + } + + public void setConnectionHops(String[] connectionHops) { + this.connectionHops = connectionHops; + } + + public String getPortId() { + return portId; + } + + public void setPortId(String portId) { + this.portId = portId; + } + + public String getChannelId() { + return channelId; + } + + public void setChannelId(String channelId) { + this.channelId = channelId; + } + + public Counterparty getCounterParty() { + return counterParty; + } + + public void setCounterParty(Counterparty counterParty) { + this.counterParty = counterParty; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/contracts/javascore/modules/ics20app/build.gradle b/contracts/javascore/modules/ics20app/build.gradle new file mode 100644 index 000000000..67f30ffef --- /dev/null +++ b/contracts/javascore/modules/ics20app/build.gradle @@ -0,0 +1,80 @@ +version = '0.1.0' + +dependencies { + compileOnly("foundation.icon:javaee-api:$javaeeVersion") + implementation("foundation.icon:javaee-scorex:$scorexVersion") + implementation project(':lib') + implementation project(':score-util') + implementation project(':ibc') + + testImplementation 'com.google.protobuf:protobuf-javalite:3.13.0' + testImplementation 'foundation.icon:javaee-rt:0.9.3' + testImplementation("org.mockito:mockito-core:$mockitoCoreVersion") + testImplementation("org.mockito:mockito-inline:$mockitoCoreVersion") + testImplementation("foundation.icon:javaee-unittest:$javaeeUnittestVersion") + testAnnotationProcessor("foundation.icon:javaee-score-client:$scoreClientVersion") + testImplementation project(':test-lib') + testImplementation("foundation.icon:javaee-score-client:$scoreClientVersion") + testImplementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + testImplementation("foundation.icon:icon-sdk:$iconsdkVersion") + testImplementation("org.junit.jupiter:junit-jupiter-api:$jupiterApiVersion") + testImplementation("org.junit.jupiter:junit-jupiter-params:$jupiterParamsVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$jupiterEngineVersion") +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.outputLocation = layout.buildDirectory.dir('jacocoHtml') + } +} + +tasks.named('compileJava') { + dependsOn(':ibc:optimizedJar') + dependsOn(':score-util:jar') + dependsOn(':lib:jar') +} + +optimizedJar { + mainClassName = 'ibc.ics20app.ICS20TransferBank' + 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 = "$ics20app"?:null + } + } + keystore = rootProject.hasProperty('keystoreName') ? "$keystoreName" : '' + password = rootProject.hasProperty('keystorePass') ? "$keystorePass" : '' + parameters { +// arg('_ibcHandler', "$ibcCore"?:null) +// arg("_bank","$ics20Bank"?:null) + } +} \ No newline at end of file diff --git a/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20App/ICS20Lib.java b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20App/ICS20Lib.java new file mode 100644 index 000000000..fccbf7815 --- /dev/null +++ b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20App/ICS20Lib.java @@ -0,0 +1,110 @@ +package ibc.ics20app; + +import ibc.ics24.host.IBCCommitment; +import ibc.icon.score.util.StringUtil; +import java.math.BigInteger; + +public class ICS20Lib { + + public static class PacketData { + 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 byte[] KECCAK256_SUCCESSFUL_ACKNOWLEDGEMENT_JSON = IBCCommitment.keccak256(SUCCESSFUL_ACKNOWLEDGEMENT_JSON); + 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 char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + + 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 byte[] marshalUnsafeJSON(PacketData data) { + if (data.memo.isEmpty()) { + return marshalJson(data.denom, data.amount, data.sender, data.receiver); + } else { + return marshalJson(data.denom, data.amount, data.sender, data.receiver, data.memo); + } + } + + + public static byte[] marshalJson(String escapedDenom, BigInteger amount, String escapedSender, String escapedReceiver) { + String jsonString = "{" + + "\"amount\":\"" + amount.toString() + "\"," + + "\"denom\":\"" + escapedDenom + "\"," + + "\"receiver\":\"" + escapedReceiver + "\"," + + "\"sender\":\"" + escapedSender + "\"" + + "}"; + + return jsonString.getBytes(); + } + + public static byte[] marshalJson(String escapedDenom, BigInteger amount, String escapedSender, String escapedReceiver, String escapedMemo) { + String jsonString = "{" + + "\"amount\":\"" + amount.toString() + "\"," + + "\"denom\":\"" + escapedDenom + "\"," + + "\"receiver\":\"" + escapedReceiver + "\"," + + "\"sender\":\"" + escapedSender + "\"," + + "\"memo\":\"" + escapedMemo + "\"" + + "}"; + + return jsonString.getBytes(); + } + + public static PacketData unmarshalJSON(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, ','); + + PacketData data = new PacketData(); + + 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/modules/ics20app/src/main/java/ibc/ics20app/ICS20Transfer.java b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20app/ICS20Transfer.java new file mode 100644 index 000000000..3e4742c49 --- /dev/null +++ b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20app/ICS20Transfer.java @@ -0,0 +1,214 @@ +package ibc.ics20app; + +import ibc.icon.interfaces.IIBCModule; +import ibc.icon.score.util.StringUtil; +import ibc.ics23.commitment.Ops; + +import icon.proto.core.channel.Channel; +import icon.proto.core.channel.Packet; +import score.Address; +import score.Context; +import score.DictDB; +import score.annotation.External; + +import java.math.BigInteger; +import java.util.Arrays; + +import static ibc.ics20app.ICS20TransferBank.bank; + +public abstract class ICS20Transfer implements IIBCModule { + public static final String ICS20_VERSION = "ics20-1"; + public static final Address ZERO_ADDRESS = Address.fromString("hx0000000000000000000000000000000000000000"); + public static final DictDB channelEscrowAddresses = Context.newDictDB("channelEscrowAddresses", Address.class); + protected final DictDB destinationPort = Context.newDictDB("destinationPort", String.class); + protected final DictDB destinationChannel = Context.newDictDB("destinationChannel", String.class); + + @External(readonly = true) + public Address getIBCAddress() { + return ICS20TransferBank.ibcHandler.getOrDefault(ZERO_ADDRESS); + } + + public void onlyIBC() { + Context.require(Context.getCaller().equals(getIBCAddress()), "ICS20App: Caller is not IBC Contract"); + } + + @External(readonly = true) + public String getDestinationPort(String channelId) { + return destinationPort.get(channelId); + } + + @External(readonly = true) + public String getDestinationChannel(String channelId) { + return destinationChannel.get(channelId); + } + + @External + public byte[] onRecvPacket(byte[] packet, Address relayer) { + onlyIBC(); + Packet packetDb = Packet.decode(packet); + ICS20Lib.PacketData data = ICS20Lib.unmarshalJSON(packetDb.getData()); + boolean success = _decodeReceiver(data.receiver); + if (!success) { + return ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON; + } + Address receiver = Address.fromString(data.receiver); + + byte[] denomPrefix = getDenomPrefix(packetDb.getSourcePort(), packetDb.getSourceChannel()); + byte[] denom = data.denom.getBytes(); + + if (denom.length >= denomPrefix.length && Ops.hasPrefix(denom, denomPrefix)) { + byte[] unprefixedDenom = Arrays.copyOfRange(denom, denomPrefix.length, denom.length); + String unprefixedDenomString = new String(unprefixedDenom); + if (unprefixedDenomString.equals("icx")){ + success = _transferICX(receiver, data.amount); + } + else { + success = _transferFrom(getEscrowAddress(packetDb.getDestinationChannel()), receiver, unprefixedDenomString, data.amount); + } + } else { + if (ICS20Lib.isEscapeNeededString(denom)) { + success = false; + } else { + denom = StringUtil.encodePacked(packetDb.getDestinationPort(), "/", packetDb.getDestinationChannel(), "/", data.denom); + String denomText = new String(denom); + success = _mint(receiver, denomText, data.amount); + } + } + + if (success) { + return ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON; + } else { + return ICS20Lib.FAILED_ACKNOWLEDGEMENT_JSON; + } + } + + + @External + public void onAcknowledgementPacket(byte[] packet, byte[] acknowledgement, Address relayer) { + onlyIBC(); + Packet packetDb = Packet.decode(packet); + if (!acknowledgement.equals(ICS20Lib.SUCCESSFUL_ACKNOWLEDGEMENT_JSON)) { + refundTokens(ICS20Lib.unmarshalJSON(packetDb.getData()), packetDb.getSourcePort(), packetDb.getSourceChannel()); + } + } + + @External + public void onChanOpenInit(int order, String[] connectionHops, String portId, String channelId, + byte[] counterpartyPb, String version) { + onlyIBC(); + Context.require(order == Channel.Order.ORDER_UNORDERED, "must be unordered"); + Context.require(version.equals(ICS20_VERSION), "version should be same with ICS20_VERSION"); + Channel.Counterparty counterparty = Channel.Counterparty.decode(counterpartyPb); + destinationPort.set(channelId, counterparty.getPortId()); + channelEscrowAddresses.set(channelId, Context.getAddress()); + } + + @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, "must be unordered"); + Context.require(counterPartyVersion.equals(ICS20_VERSION), "version should be same with ICS20_VERSION"); + Channel.Counterparty counterparty = Channel.Counterparty.decode(counterpartyPb); + destinationPort.set(channelId, counterparty.getPortId()); + destinationChannel.set(channelId, counterparty.getChannelId()); + channelEscrowAddresses.set(channelId, Context.getAddress()); + } + + @External + public void onChanOpenAck(String portId, String channelId, String counterpartyChannelId, String counterPartyVersion) { + onlyIBC(); + Context.require(counterPartyVersion.equals(ICS20_VERSION), "version should be same with ICS20_VERSION"); + + } + + @External + public void onChanCloseInit(String portId, String channelId) { + Context.revert("Not Allowed"); + } + + @External + public void onTimeoutPacket(byte[] packet, Address relayer) { + Packet packetDb = Packet.decode(packet); + ICS20Lib.PacketData data = ICS20Lib.unmarshalJSON(packetDb.getData()); + refundTokens(data, packetDb.getSourcePort(), packetDb.getSourceChannel()); + } + + @External + public void onChanCloseConfirm(String portId, String channelId) { + onlyIBC(); + Context.println("onChanCloseConfirm"); + } + + @External + public void onChanOpenConfirm(String portId, String channelId) { + onlyIBC(); + Context.println("onChanOpenConfirm"); + } + + + static Address getEscrowAddress(String sourceChannel) { + Address escorw = channelEscrowAddresses.get(sourceChannel); + Context.require(escorw != ZERO_ADDRESS); + return escorw; + } + + @External(readonly = true) + public Address escrowAddress(String sourceChannel){ + return getEscrowAddress(sourceChannel); + } + + private void refundTokens(ICS20Lib.PacketData data, String sourcePort, String sourceChannel) { + byte[] denomPrefix = getDenomPrefix(sourcePort, sourceChannel); + byte[] denom = data.denom.getBytes(); + + if (denom.length >= denomPrefix.length && Ops.hasPrefix(denom, denomPrefix)) { + Context.require(_mint(Address.fromString(data.sender), data.denom, data.amount), "ICS20: mint failed"); + } else { + Context.require(_transferFrom(getEscrowAddress(sourceChannel), Address.fromString(data.sender), data.denom, data.amount), "ICS20: transfer failed"); + } + } + + public static byte[] getDenomPrefix(String port, String channel) { + return StringUtil.encodePacked(port, "/", channel, "/"); + } + + boolean _transferFrom(Address sender, Address receiver, String denom, BigInteger amount) { + Context.call(bank.get(), "transferFrom", sender, receiver, denom, amount); + return true; + } + + private boolean _mint(Address account, String denom, BigInteger amount) { + Context.call(bank.get(), "mint", account, denom, amount); + return true; + } + + boolean _burn(Address account, String denom, BigInteger amount) { + Context.call(bank.get(), "burn", account, denom, amount); + return true; + } + + boolean _transferICX(Address receiver, BigInteger amount) { + Context.require(Context.getBalance(Context.getAddress()).compareTo(amount) >= 0, "ICS20App: insufficient balance for transfer"); + Context.transfer(receiver, amount); + return true; + } + + /** + * @dev _decodeReceiver decodes a hex string to an address. + * `receiver` may be an invalid address format. + */ + protected static boolean _decodeReceiver(String receiver) { + boolean flag; + try { + Address.fromString(receiver); + flag = true; + } catch (Exception e) { + flag = false; + + } + return flag; + } + + +} diff --git a/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20app/ICS20TransferBank.java b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20app/ICS20TransferBank.java new file mode 100644 index 000000000..b554174ae --- /dev/null +++ b/contracts/javascore/modules/ics20app/src/main/java/ibc/ics20app/ICS20TransferBank.java @@ -0,0 +1,74 @@ +package ibc.ics20app; + +import icon.proto.core.channel.Packet; +import icon.proto.core.client.Height; +import score.Address; +import score.Context; +import score.VarDB; +import score.annotation.External; +import score.annotation.Payable; + +import java.math.BigInteger; + +public class ICS20TransferBank extends ICS20Transfer { + public static final VarDB
ibcHandler = Context.newVarDB("ibcHandler", Address.class); + public static final VarDB
bank = Context.newVarDB("bank", Address.class); + + public static final String TAG = "ICS20App"; + + public ICS20TransferBank(Address _ibcHandler, Address _bank) { + if (ibcHandler.get() == null) { + ibcHandler.set(_ibcHandler); + bank.set(_bank); + } + } + + @External(readonly = true) + public Address getBank() { + return bank.getOrDefault(ZERO_ADDRESS); + } + + @External(readonly = true) + public BigInteger getBankBalance() { + return Context.getBalance(Context.getAddress()); + } + + @Payable + @External + public void sendTransfer(String denom, BigInteger amount, String receiver, String sourcePort, String sourceChannel, BigInteger timeoutHeight, BigInteger timeoutRevisionNumber) { + Address caller = Context.getCaller(); + if (denom.equals("icx")) { + Context.require(Context.getValue().compareTo(BigInteger.ZERO) > 0, "ICS20App: icx transfer failed"); + Context.require(Context.getValue().compareTo(amount) == 0, "ICS20App: icx value is not equal to amount sent"); + } else { + byte[] denomPrefix = ICS20Transfer.getDenomPrefix(sourcePort, sourceChannel); + String denomText = new String(denomPrefix); + if (!denom.startsWith(denomText)) { + Context.require(_transferFrom(caller, ICS20Transfer.getEscrowAddress(sourceChannel), denom, amount), "ICS20App: transfer failed"); + } else { + Context.require(_burn(caller, denom, amount), "ICS20App: Burn failed"); + } + } + + Height height = new Height(); + height.setRevisionNumber(timeoutRevisionNumber); + height.setRevisionHeight(timeoutHeight); + + byte[] data = ICS20Lib.marshalJson(denom, amount, caller.toString(), receiver); + + BigInteger seq = (BigInteger) Context.call(ibcHandler.get(), "getNextSequenceSend", sourcePort, sourceChannel); + Packet newPacket = new Packet(); + newPacket.setSequence(seq); + newPacket.setSourcePort(sourcePort); + newPacket.setSourceChannel(sourceChannel); + newPacket.setDestinationPort(destinationPort.get(sourceChannel)); + newPacket.setDestinationChannel(destinationChannel.get(sourceChannel)); + newPacket.setTimeoutHeight(height); + newPacket.setTimeoutTimestamp(BigInteger.ZERO); + newPacket.setData(data); + + Context.call(ibcHandler.get(), "sendPacket", newPacket.encode()); + } + + +} diff --git a/contracts/javascore/modules/ics20app/src/test/java/ibc.ics20app/ICS20TransferTest.java b/contracts/javascore/modules/ics20app/src/test/java/ibc.ics20app/ICS20TransferTest.java new file mode 100644 index 000000000..237c21894 --- /dev/null +++ b/contracts/javascore/modules/ics20app/src/test/java/ibc.ics20app/ICS20TransferTest.java @@ -0,0 +1,146 @@ +package ibc.ics20app; + + +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 ibc.icon.structs.messages.MsgChannelOpenInit; +import ibc.ics23.commitment.Ops; +import icon.proto.core.channel.Packet; +import icon.proto.core.client.Height; +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 java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.spy; + +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 org.junit.jupiter.api.*; +import org.junit.jupiter.api.function.Executable; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + + +public class ICS20TransferTest extends TestBase { + public static final Address SYSTEM_ADDRESS = Address.fromString("cx0000000000000000000000000000000000000000"); + public static final Address ICS20Bank = Address.fromString("cx0000000000000000000000000000000000000002"); + public static final Address IBCHandler = Address.fromString("cx0000000000000000000000000000000000000003"); + public static final Address ZERO_ADDRESS = Address.fromString("hx0000000000000000000000000000000000000000"); + + public static final ServiceManager sm = getServiceManager(); + public static final Account owner = sm.createAccount(); + public static final Account testingAccount = sm.createAccount(); + public static final String TAG = "ICS20App"; + public Score ics20App; + ICS20TransferBank ICS20TransferBankSpy; + + public static MockedStatic contextMock; + + @BeforeEach + public void setup() throws Exception { + ics20App = sm.deploy(owner, ICS20TransferBank.class, IBCHandler, ICS20Bank); + + ICS20TransferBank instance = (ICS20TransferBank) ics20App.getInstance(); + ICS20TransferBankSpy = spy(instance); + ics20App.setInstance(ICS20TransferBankSpy); + contextMock.reset(); + } + + @BeforeAll + public static void init(){ + contextMock = Mockito.mockStatic(Context.class, CALLS_REAL_METHODS); + } + + + public void expectErrorMessage(Executable contractCall, String errorMessage) { + AssertionError e = Assertions.assertThrows(AssertionError.class, contractCall); + assertEquals(errorMessage, e.getMessage()); + } + + @Test + void getBank(){ + assertEquals(ICS20Bank, ics20App.call("getBank")); + } + + @Test + void getIBCAddress(){ + assertEquals(IBCHandler, ics20App.call("getIBCAddress")); + } + +// @Test +// void getDestinationPort(){ +// String channelId = "channel-0"; +// assertEquals("transfer", ics20App.call("getDestinationPort", channelId)); +// } +// +// @Test +// void getDestinationChannel(){ +// String channelId = "channel-0"; +// assertEquals("channel-1", ics20App.call("getDestinationChannel", channelId)); +// } + + @Test + void onlyIBCFailure(){ + expectErrorMessage( + () -> ics20App.invoke(testingAccount, "onlyIBC"), + "Reverted(0): ICS20App: Caller is not IBC Contract" + ); + } + + @Test + void onlyIBCSuccess(){ + contextMock.when(Context::getCaller).thenReturn(IBCHandler); + ics20App.invoke(owner, "onlyIBC"); + } + + +// @Test +// void onRecvPacket() { +// BigInteger sequence = BigInteger.ONE; +// String sourcePort = "transfer"; +// String sourceChannel = "channel-0"; +// String destinationPort = "transfer"; +// String destinationChannel = "channel-1"; +// byte[] data = "{\"amount\":\"99000000000000000\",\"denom\":\"stake\",\"receiver\":\"hxb6b5791be0b5ef67063b3c10b840fb81514db2fd\",\"sender\":\"centauri1g5r2vmnp6lta9cpst4lzc4syy3kcj2ljte3tlh\"}".getBytes(); +// BigInteger timeoutTimestamp = BigInteger.ZERO; +// BigInteger timeoutHeight = BigInteger.ZERO; +// +// Height height = new Height(); +// height.setRevisionNumber(timeoutHeight); +// height.setRevisionHeight(timeoutHeight); +// +// Packet packet = new Packet(); +// packet.setSequence(sequence); +// packet.setSourcePort(sourcePort); +// packet.setSourceChannel(sourceChannel); +// packet.setDestinationPort(destinationPort); +// packet.setDestinationChannel(destinationChannel); +// packet.setTimeoutHeight(new Height()); +// packet.setTimeoutTimestamp(timeoutTimestamp); +// packet.setData(data); +// +// byte[] packetBytes = packet.encode(); +// +// contextMock.when(caller()).thenReturn(IBCHandler); +// ics20App.invoke(owner, "onRecvPacket", packetBytes, ZERO_ADDRESS); +// +// } + + public MockedStatic.Verification caller(){ + return () -> Context.getCaller(); + } + + +} \ No newline at end of file diff --git a/contracts/javascore/modules/ics20bank/build.gradle b/contracts/javascore/modules/ics20bank/build.gradle new file mode 100644 index 000000000..5983f1eca --- /dev/null +++ b/contracts/javascore/modules/ics20bank/build.gradle @@ -0,0 +1,79 @@ +version = '0.1.0' + +dependencies { + compileOnly("foundation.icon:javaee-api:$javaeeVersion") + implementation("foundation.icon:javaee-scorex:$scorexVersion") + implementation project(':lib') + implementation project(':score-util') + implementation project(':ibc') + + + testImplementation 'com.google.protobuf:protobuf-javalite:3.13.0' + testImplementation 'foundation.icon:javaee-rt:0.9.3' + testImplementation("org.mockito:mockito-core:$mockitoCoreVersion") + testImplementation("org.mockito:mockito-inline:$mockitoCoreVersion") + testImplementation("foundation.icon:javaee-unittest:$javaeeUnittestVersion") + testAnnotationProcessor("foundation.icon:javaee-score-client:$scoreClientVersion") + testImplementation project(':test-lib') + testImplementation("foundation.icon:javaee-score-client:$scoreClientVersion") + testImplementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + testImplementation("foundation.icon:icon-sdk:$iconsdkVersion") + testImplementation("org.junit.jupiter:junit-jupiter-api:$jupiterApiVersion") + testImplementation("org.junit.jupiter:junit-jupiter-params:$jupiterParamsVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$jupiterEngineVersion") +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.outputLocation = layout.buildDirectory.dir('jacocoHtml') + } +} + +tasks.named('compileJava') { + dependsOn(':ibc:optimizedJar') + dependsOn(':score-util:jar') + dependsOn(':lib:jar') +} + +optimizedJar { + mainClassName = 'ibc.ics20bank.ICS20Bank' + 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 = "$ics20app"?:null + } + } + keystore = rootProject.hasProperty('keystoreName') ? "$keystoreName" : '' + password = rootProject.hasProperty('keystorePass') ? "$keystorePass" : '' + parameters { + } +} \ No newline at end of file diff --git a/contracts/javascore/modules/ics20bank/src/main/java/ibc/ics20bank/ICS20Bank.java b/contracts/javascore/modules/ics20bank/src/main/java/ibc/ics20bank/ICS20Bank.java new file mode 100644 index 000000000..3b33f4f59 --- /dev/null +++ b/contracts/javascore/modules/ics20bank/src/main/java/ibc/ics20bank/ICS20Bank.java @@ -0,0 +1,113 @@ +package ibc.ics20bank; + +import score.*; +import score.annotation.External; + +import java.math.BigInteger; + +import scorex.util.HashMap; + +import java.util.Map; + +public class ICS20Bank { + + public static final String ICS20_VERSION = "ics20-1"; + public static final Address ZERO_ADDRESS = Address.fromString("hx0000000000000000000000000000000000000000"); + + public static final String TAG = "ICS20Bank"; + + private static final Integer ADMIN_ROLE_ID = 1; + private static final Integer OPERATOR_ROLE_ID = 2; + + + // Mapping from token ID to account balances + private final BranchDB> balances = Context.newBranchDB("BALANCES", BigInteger.class); + private final DictDB roles = Context.newDictDB("ROLES", Integer.class); + + + public ICS20Bank() { + if (roles.get(Context.getOwner()) == null) { + setupRole(ADMIN_ROLE_ID, Context.getOwner()); + } + } + + @External + public void setupRole(int role, Address account) { + Context.require(Context.getCaller().equals(Context.getOwner()), "Only owner can set up role"); + roles.set(account, role); + } + + @External + public void setupOperator(Address account) { + setupRole(OPERATOR_ROLE_ID, account); + } + + private boolean hasRole(int role, Address account) { + return (roles.getOrDefault(account, 0) == role); + } + + @External(readonly = true) + public int getRole(Address account) { + return roles.getOrDefault(account, 0); + } + + @External(readonly = true) + public BigInteger balanceOf(Address account, String denom) { + return balances.at(denom).getOrDefault(account, BigInteger.ZERO); + } + + @External + public void transferFrom(Address from, Address to, String denom, BigInteger amount) { + Context.require(to != ZERO_ADDRESS, TAG + ": balance query for the zero address"); + Address caller = Context.getCaller(); + Context.require(from.equals(caller) || hasRole(OPERATOR_ROLE_ID, caller), TAG + ": caller is not owner nor approved"); + Context.require(!from.equals(to), TAG + ": sender and receiver is same"); + BigInteger fromBalance = balanceOf(from, denom); + Context.require(amount.compareTo(BigInteger.ZERO) > 0, TAG + ": transfer amount must be greater than zero"); + Context.require(fromBalance.compareTo(amount) >= 0, TAG + ": insufficient balance for transfer"); + + balances.at(denom).set(from, fromBalance.subtract(amount)); + balances.at(denom).set(to, balanceOf(to, denom).add(amount)); + } + + @External + public void mint(Address account, String denom, BigInteger amount) { + Context.require(hasRole(OPERATOR_ROLE_ID, Context.getCaller()), TAG + ": must have minter role to mint"); + Context.require(account != ZERO_ADDRESS, TAG + ": mint to the zero address"); + Context.require(amount.compareTo(BigInteger.ZERO) > 0, TAG + ": mint amount must be greater than zero"); + _mint(account, denom, amount); + } + + @External + public void burn(Address account, String denom, BigInteger amount) { + Context.require(hasRole(OPERATOR_ROLE_ID, Context.getCaller()), TAG + ": must have burn role to burn"); + Context.require(amount.compareTo(BigInteger.ZERO) > 0, TAG + ": burn amount must be greater than zero"); + _burn(account, denom, amount); + } + + @External + public void deposit(Address tokenContract, BigInteger amount, Address receiver) { + Context.require(tokenContract.isContract(), TAG + ": tokenContract is not a contract"); + Context.call(tokenContract, "transferFrom", Context.getCaller(), Context.getAddress(), amount); + _mint(receiver, tokenContract.toString(), amount); + } + + @External + public void withdraw(Address tokenContract, BigInteger amount) { + Context.require(tokenContract.isContract(), TAG + ": tokenContract is not a contract"); + Address receiver = Context.getCaller(); + _burn(receiver, tokenContract.toString(), amount); + Context.call(tokenContract, "transfer", receiver, amount); + } + + private void _mint(Address account, String denom, BigInteger amount) { + balances.at(denom).set(account, balanceOf(account, denom).add(amount)); + } + + private void _burn(Address account, String denom, BigInteger amount) { + BigInteger accountBalance = balanceOf(account, denom); + Context.require(accountBalance.compareTo(amount) >= 0, TAG + ": burn amount exceeds balance"); + BigInteger newBalance = accountBalance.subtract(amount); + balances.at(denom).set(account, newBalance); + } +} diff --git a/contracts/javascore/modules/ics20bank/src/test/java/ibc/ics20bank/ICS20BankTest.java b/contracts/javascore/modules/ics20bank/src/test/java/ibc/ics20bank/ICS20BankTest.java new file mode 100644 index 000000000..db68c4c94 --- /dev/null +++ b/contracts/javascore/modules/ics20bank/src/test/java/ibc/ics20bank/ICS20BankTest.java @@ -0,0 +1,127 @@ +package ibc.ics20bank; + + +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 org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import score.Context; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.spy; + + +public class ICS20BankTest extends TestBase { + public static final ServiceManager sm = getServiceManager(); + public static final Account owner = sm.createAccount(); + public static final Account testingAccount = sm.createAccount(); + public static final Account testingAccount2 = sm.createAccount(); + public static final String TAG = "ICS20Bank"; + public Score ics20Bank; + ICS20Bank ICS20BankSpy; + + public static MockedStatic contextMock; + + @BeforeEach + public void setup() throws Exception { + ics20Bank = sm.deploy(owner, ICS20Bank.class); + + ICS20Bank instance = (ICS20Bank) ics20Bank.getInstance(); + ICS20BankSpy = spy(instance); + ics20Bank.setInstance(ICS20BankSpy); + ics20Bank.invoke(owner, "setupRole", 1, owner.getAddress()); + ics20Bank.invoke(owner, "setupRole", 2, owner.getAddress()); + contextMock.reset(); + } + + @BeforeAll + public static void init() { + contextMock = Mockito.mockStatic(Context.class, CALLS_REAL_METHODS); + } + + + public void expectErrorMessage(Executable contractCall, String errorMessage) { + AssertionError e = Assertions.assertThrows(AssertionError.class, contractCall); + assertEquals(errorMessage, e.getMessage()); + } + + @Test + void setupRole() { + ics20Bank.invoke(owner, "setupRole", 2, testingAccount.getAddress()); + assertEquals(2, ics20Bank.call("getRole", testingAccount.getAddress())); + } + + @Test + void mint() { + ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)); + assertEquals(BigInteger.valueOf(100), ics20Bank.call("balanceOf", new Object[]{testingAccount.getAddress(), "testDenom"})); + } + + @Test + void mintNoAccess() { + expectErrorMessage(() -> ics20Bank.invoke(testingAccount, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)), "Reverted(0): ICS20Bank: must have minter role to mint"); + } + + @Test + void mintZeroAmount() { + expectErrorMessage(() -> ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(0)), "Reverted(0): ICS20Bank: mint amount must be greater than zero"); + } + + @Test + void burn() { + ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)); + ics20Bank.invoke(owner, "burn", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(50)); + assertEquals(BigInteger.valueOf(50), ics20Bank.call("balanceOf", new Object[]{testingAccount.getAddress(), "testDenom"})); + } + + @Test + void burnNoAccess() { + expectErrorMessage(() -> ics20Bank.invoke(testingAccount, "burn", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)), "Reverted(0): ICS20Bank: must have burn role to burn"); + } + + @Test + void burnGreaterAmount() { + ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)); + expectErrorMessage(() -> ics20Bank.invoke(owner, "burn", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(150)), "Reverted(0): ICS20Bank: burn amount exceeds balance"); + } + + @Test + void burnZeroAmount() { + expectErrorMessage(() -> ics20Bank.invoke(owner, "burn", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(0)), "Reverted(0): ICS20Bank: burn amount must be greater than zero"); + } + + @Test + void transferFrom() { + ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)); + ics20Bank.invoke(owner, "transferFrom", testingAccount.getAddress(), owner.getAddress(), "testDenom", BigInteger.valueOf(50)); + assertEquals(BigInteger.valueOf(50), ics20Bank.call("balanceOf", testingAccount.getAddress(), "testDenom")); + assertEquals(BigInteger.valueOf(50), ics20Bank.call("balanceOf", owner.getAddress(), "testDenom")); + } + + @Test + void transferFromSameAddress() { + ics20Bank.invoke(owner, "mint", testingAccount.getAddress(), "testDenom", BigInteger.valueOf(100)); + expectErrorMessage(() -> ics20Bank.invoke(owner, "transferFrom", testingAccount.getAddress(), testingAccount.getAddress(), "testDenom", BigInteger.valueOf(50)), "Reverted(0): ICS20Bank: sender and receiver is same"); + } + + @Test + void transferFromNoAccess() { + expectErrorMessage(() -> ics20Bank.invoke(testingAccount, "transferFrom", owner.getAddress(), testingAccount2.getAddress(), "testDenom", BigInteger.valueOf(50)), "Reverted(0): ICS20Bank: caller is not owner nor approved"); + } + + public MockedStatic.Verification caller() { + return () -> Context.getCaller(); + } + + +} \ No newline at end of file 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 c3b35a630..ea38daa9f 100644 --- a/contracts/javascore/settings.gradle +++ b/contracts/javascore/settings.gradle @@ -18,4 +18,12 @@ project(':mockclient').name = "mockclient" include(':mockapp') project(':mockapp').projectDir = file("modules/mockapp") -project(':mockapp').name = "mockapp" \ No newline at end of file +project(':mockapp').name = "mockapp" + +include(':ics20app') +project(':ics20app').projectDir = file("modules/ics20app") +project(':ics20app').name = "ics20app" + +include(':ics20bank') +project(':ics20bank').projectDir = file("modules/ics20bank") +project(':ics20bank').name = "ics20bank" \ No newline at end of file