[java / kotlin] xrp ledger 연동
업무를 진행하면서 우리 메인넷과 ripple 메인넷 입/출금 기능을 구현하는 과제를 수행한적이 있습니다. 관련 내용을 분석하고 개발한 내용을 적어볼까 합니다. (kotlin)
XRP API 사이트 : https://xrpl.org/http-websocket-apis.html
우선 대상은
지갑정보 (account_info)
지갑 트랜잭션 정보 (account_tx)
전송 트랜잭션(payment)
으로 네트워크에 전송에 필요한 기능 위주로 우선 작업 진행하게되었습니다.
XRP client 원본
@Component
class KsXrplClient(
@Value("\${ripple.network.url}")
val url: String
) {
private lateinit var _xrplClient: XrplClient
private val log = LoggerFactory.getLogger(this.javaClass)
@PostConstruct
fun KsXrplClient() {
_xrplClient = XrplClient(url.toHttpUrl())
}
@Throws(JsonRpcClientErrorException::class)
fun getAccountInfo(account: String): AccountInfoResult {
var classicAddress = Address.of(account)
log.info("##### getAccountInfo ::: [address=$classicAddress]")
val requestParams = AccountInfoRequestParams.builder()
.account(classicAddress)
.ledgerSpecifier(LedgerSpecifier.VALIDATED)
.build()
val result = _xrplClient.accountInfo(requestParams)
log.info("##### getAccountInfo result ::: $result")
return result
}
/**
* Account Transaction 조회
*/
@Throws(JsonRpcClientErrorException::class, Exception::class)
fun getAccountTx(account: String,
forward: Boolean = false,
ledgerIndexMin: Long = -1,
ledgerIndexMax: Long = -1,
limit: Long = 10
): KsAccountTxResultDto {
var classicAddress = Address.of(account)
val params = AccountTransactionsRequestParams.unboundedBuilder()
.account(classicAddress)
.limit(UnsignedInteger.valueOf(limit))
.ledgerIndexMaximum(LedgerIndexBound.of(ledgerIndexMax))
.ledgerIndexMinimum(LedgerIndexBound.of(ledgerIndexMin))
.forward(forward)
.build()
log.info("##### getAccountTx ::: [params=$params]")
val jsonRpcParam = JsonRpcRequest.builder()
.method(XrplMethods.ACCOUNT_TX)
.addParams(params)
.build()
val jsonRpcRes = _xrplClient.jsonRpcClient.postRpcRequest(jsonRpcParam)
log.info("#### getAccountTx result ::: ${jsonRpcRes["result"]["transactions"]}")
return if(jsonRpcRes["result"]["transactions"] != null){
val txs = mutableListOf<KsAccountTxTransactionDto>()
for(jsonNode in jsonRpcRes["result"]["transactions"].toList()){
val dto = KsAccountTxTransactionDto()
log.info("### jsonNode ::: $jsonNode")
dto.convertDto(jsonNode)
txs.add(dto)
}
// val nodeList = jsonRpcRes["result"]["transactions"].toMutableList()
val txResult = KsAccountTxResultDto(
account = jsonRpcRes["result"]["account"].asText(),
ledgerIndexMax = jsonRpcRes["result"]["ledger_index_max"].asLong(),
ledgerIndexMin = jsonRpcRes["result"]["ledger_index_min"].asLong(),
limit = jsonRpcRes["result"]["limit"].asInt(),
status = jsonRpcRes["result"]["limit"].asText(),
validated = jsonRpcRes["result"]["validated"].asBoolean(),
transactions = txs
)
log.info("#### result ::: $txResult")
txResult
}
else {
KsAccountTxResultDto(
account = account
)
}
}
@Throws(JsonRpcClientErrorException::class, KstadiumApiException::class, Exception::class)
fun send(fromAddress: String, fromSeed: String, toAddress: String, amount: String, tag: String) : SubmitResult<Payment>{
//########## 1. account_info 조회
val accountInto = try {
getAccountInfo(fromAddress)
} catch (e: JsonRpcClientErrorException) {
log.error("#### [JsonRpcClientErrorException] XRP getAddressInfo ::: $e")
throw KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, e.message)
} catch (e: Exception) {
log.error("#### [Exception] XRP getAddressInfo ::: $e")
throw KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, null)
}
//########## 2. keypair 생성( by Seed )
val keyPair = Seed.fromBase58EncodedSecret(Base58EncodedSecret.of(fromSeed)).deriveKeyPair()
val sequence = accountInto.accountData().sequence()
val fee = fee()
val openLedgerFee = fee.drops().openLedgerFee()
val validatedLedgerIndex = ledgerIndex()
val lastLedgerSequence = validatedLedgerIndex.plus(UnsignedInteger.valueOf(4))
.unsignedIntegerValue()
//########## 3. payment request 객체생성
val payment = Payment.builder()
.account(accountInto.accountData().account())
.amount(XrpCurrencyAmount.ofXrp(BigDecimal(amount)))
.destination(Address.of(toAddress))
.sequence(sequence)
.fee(openLedgerFee)
.signingPublicKey(keyPair.publicKey())
.lastLedgerSequence(lastLedgerSequence)
try {
if(tag != null){
payment.destinationTag( UnsignedInteger.valueOf(tag) )
}
}
catch (e: Exception){
log.warn("Xrpl DestinationTag is Invalid. ::: $tag")
}
//########## 4. sign
log.info("### Begin Sign XRP Payment ... from=$fromAddress, to=$toAddress, amount=$amount")
val signed = sign(keyPair, payment.build())
//########## 5. submit
val paymentSubmitResult: SubmitResult<Payment> = _xrplClient.submit(signed)
return paymentSubmitResult
}
fun sign(keyPair: KeyPair, input: Payment) : SingleSignedTransaction<Payment>{
val signatureService: SignatureService<PrivateKey> = BcSignatureService()
val signed: SingleSignedTransaction<Payment> = signatureService.sign(keyPair.privateKey(), input)
log.info("#### signed payment ::: ${signed.signedTransaction()}")
return signed
}
fun sign(seed: String, input: UnsignedClaim) : Signature {
val keyPair = Seed.fromBase58EncodedSecret(Base58EncodedSecret.of(seed)).deriveKeyPair()
val signatureService: SignatureService<PrivateKey> = BcSignatureService()
return signatureService.sign(keyPair.privateKey(), input)
}
/**
* get Fee
*/
fun fee() = _xrplClient.fee()
/**
* get ledger info
*/
fun ledger() = _xrplClient.ledger(LedgerRequestParams.builder().ledgerSpecifier(LedgerSpecifier.VALIDATED).build())
/**
* get ledger Index
*/
@Throws(KstadiumApiException::class, Exception::class)
fun ledgerIndex() = ledger().ledgerIndex().orElseThrow {
KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, "LedgerIndex not available.")
}
Account_info
공식 문서 화면
@Throws(JsonRpcClientErrorException::class)
fun getAccountInfo(account: String): AccountInfoResult {
log.info("#### _xrplClient ::: ${System.identityHashCode(_xrplClient)}")
var classicAddress = Address.of(account)
log.info("##### getAccountInfo ::: [address=$classicAddress]")
val requestParams = AccountInfoRequestParams.builder()
.account(classicAddress)
.ledgerSpecifier(LedgerSpecifier.VALIDATED)
.build()
val result = _xrplClient.accountInfo(requestParams)
log.info("##### getAccountInfo result ::: $result")
return result
}
accountInfo의 경우 어려운 부분은 없었고 아래와 같이 (example) xrp4j 에 구현된 객체로 response 받을수 있습니다.
{
"id": 5,
"status": "success",
"type": "response",
"result": {
"account_data": {
"Account": "rG1QQv2nh2gr7RCZ1P8YYcBUKCCN633jCn",
"Balance": "999999999960",
"Flags": 8388608,
"LedgerEntryType": "AccountRoot",
"OwnerCount": 0,
"PreviousTxnID": "4294BEBE5B569A18C0A2702387C9B1E7146DC3A5850C1E87204951C6FDAA4C42",
"PreviousTxnLgrSeq": 3,
"Sequence": 6,
"index": "92FA6A9FC8EA6018D5D16532D7795C91BFB0831355BDFDA177E86C8BF997985F"
},
"ledger_current_index": 4,
"queue_data": {
"auth_change_queued": true,
"highest_sequence": 10,
"lowest_sequence": 6,
"max_spend_drops_total": "500",
"transactions": [
{
"auth_change": false,
"fee": "100",
"fee_level": "2560",
"max_spend_drops": "100",
"seq": 6
},
... (trimmed for length) ...
{
"LastLedgerSequence": 10,
"auth_change": true,
"fee": "100",
"fee_level": "2560",
"max_spend_drops": "100",
"seq": 10
}
],
"txn_count": 5
},
"validated": false
}
}
account_tx
공식 문서 화면
@Throws(JsonRpcClientErrorException::class, Exception::class)
fun getAccountTx(account: String,
forward: Boolean = false,
ledgerIndexMin: Long = -1,
ledgerIndexMax: Long = -1,
limit: Long = 10
): KsAccountTxResultDto {
var classicAddress = Address.of(account)
val params = AccountTransactionsRequestParams.unboundedBuilder()
.account(classicAddress)
.limit(UnsignedInteger.valueOf(limit))
.ledgerIndexMaximum(LedgerIndexBound.of(ledgerIndexMax))
.ledgerIndexMinimum(LedgerIndexBound.of(ledgerIndexMin))
.forward(forward)
.build()
log.info("##### getAccountTx ::: [params=$params]")
val jsonRpcParam = JsonRpcRequest.builder()
.method(XrplMethods.ACCOUNT_TX)
.addParams(params)
.build()
val jsonRpcRes = _xrplClient.jsonRpcClient.postRpcRequest(jsonRpcParam)
log.info("#### getAccountTx result ::: ${jsonRpcRes["result"]["transactions"]}")
return if(jsonRpcRes["result"]["transactions"] != null){
val txs = mutableListOf<KsAccountTxTransactionDto>()
for(jsonNode in jsonRpcRes["result"]["transactions"].toList()){
val dto = KsAccountTxTransactionDto()
log.info("### jsonNode ::: $jsonNode")
dto.convertDto(jsonNode)
txs.add(dto)
}
// val nodeList = jsonRpcRes["result"]["transactions"].toMutableList()
val txResult = KsAccountTxResultDto(
account = jsonRpcRes["result"]["account"].asText(),
ledgerIndexMax = jsonRpcRes["result"]["ledger_index_max"].asLong(),
ledgerIndexMin = jsonRpcRes["result"]["ledger_index_min"].asLong(),
limit = jsonRpcRes["result"]["limit"].asInt(),
status = jsonRpcRes["result"]["limit"].asText(),
validated = jsonRpcRes["result"]["validated"].asBoolean(),
transactions = txs
)
log.info("#### result ::: $txResult")
txResult
}
else {
KsAccountTxResultDto(
account = account
)
}
}
....
fun convertDto(jsonNodes: JsonNode) {
try {
this.tx = objectMapper.treeToValue(jsonNodes["tx"], KsAccountTxTransactionTxDto::class.java)
// this.meta = objectMapper.treeToValue(jsonNodes["meta"], KsAccountTxTransactionMetaDto::class.java)
} catch (e: Exception) {
e.printStackTrace()
}
}
구현 하면서 애를 먹었던.... 기능으로 AWS SDK 사용하고 있는데... 글쎄.. xray-recorder-sdk 내부에 삭제된 상수를 사용하고 있어 "jackson-databind" lib 버전을 올리지 못하게 되었습니다.
xrp4j에서 제공되는 함수 accountTransactions 사용하지 못하여 직접 RpcClient 요청후 전달받은 JsonNode ObjectMapper 사용하여 데이터 파싱하는 로직을 추가해주게 되었습니다.
Payment
account_info 조회
keypair 생성( by Seed )
payment request 객체생성
sign
submit
순으로 진행 하였습니다.
@Throws(JsonRpcClientErrorException::class, KstadiumApiException::class, Exception::class)
fun send(fromAddress: String, fromSeed: String, toAddress: String, amount: String, tag: String) : SubmitResult<Payment>{
//########## 1. account_info 조회
val accountInto = try {
getAccountInfo(fromAddress)
} catch (e: JsonRpcClientErrorException) {
log.error("#### [JsonRpcClientErrorException] XRP getAddressInfo ::: $e")
throw KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, e.message)
} catch (e: Exception) {
log.error("#### [Exception] XRP getAddressInfo ::: $e")
throw KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, null)
}
//########## 2. keypair 생성( by Seed )
val keyPair = Seed.fromBase58EncodedSecret(Base58EncodedSecret.of(fromSeed)).deriveKeyPair()
val sequence = accountInto.accountData().sequence()
val fee = fee()
val openLedgerFee = fee.drops().openLedgerFee()
val validatedLedgerIndex = ledgerIndex()
val lastLedgerSequence = validatedLedgerIndex.plus(UnsignedInteger.valueOf(4))
.unsignedIntegerValue()
//########## 3. payment request 객체생성
val payment = Payment.builder()
.account(accountInto.accountData().account())
.amount(XrpCurrencyAmount.ofXrp(BigDecimal(amount)))
.destination(Address.of(toAddress))
.sequence(sequence)
.fee(openLedgerFee)
.signingPublicKey(keyPair.publicKey())
.lastLedgerSequence(lastLedgerSequence)
try {
if(tag != null){
payment.destinationTag( UnsignedInteger.valueOf(tag) )
}
}
catch (e: Exception){
log.warn("Xrpl DestinationTag is Invalid. ::: $tag")
}
//########## 4. sign
log.info("### Begin Sign XRP Payment ... from=$fromAddress, to=$toAddress, amount=$amount")
val signed = sign(keyPair, payment.build())
//########## 5. submit
val paymentSubmitResult: SubmitResult<Payment> = _xrplClient.submit(signed)
return paymentSubmitResult
}
//private key, public key 로 sign : 공식문서 분석해 봐선 자주 쓰이지 않을듯
fun sign(keyPair: KeyPair, input: Payment) : SingleSignedTransaction<Payment>{
val signatureService: SignatureService<PrivateKey> = BcSignatureService()
val signed: SingleSignedTransaction<Payment> = signatureService.sign(keyPair.privateKey(), input)
log.info("#### signed payment ::: ${signed.signedTransaction()}")
return signed
}
// seed로 sign
fun sign(seed: String, input: UnsignedClaim) : Signature {
val keyPair = Seed.fromBase58EncodedSecret(Base58EncodedSecret.of(seed)).deriveKeyPair()
val signatureService: SignatureService<PrivateKey> = BcSignatureService()
return signatureService.sign(keyPair.privateKey(), input)
}
/**
* get Fee
*/
fun fee() = _xrplClient.fee()
/**
* get ledger info
*/
fun ledger() = _xrplClient.ledger(LedgerRequestParams.builder().ledgerSpecifier(LedgerSpecifier.VALIDATED).build())
/**
* get ledger Index
*/
@Throws(KstadiumApiException::class, Exception::class)
fun ledgerIndex() = ledger().ledgerIndex().orElseThrow {
KstadiumApiException(KsResponse.KS_BLOCKCHAIN_NETWORK_ERROR, "LedgerIndex not available.")
}
끝으로
테스트는 아래의 정보에 지갑생성 및 테스트를 진행해보았습니다.
테스트넷 : https://s.altnet.rippletest.net:51234/
테스트넷 지갑생성 : https://xrpl.org/xrp-testnet-faucet.html
지갑 UI : 크롬 익스펜션 OsmWallet 사용
[참고]