[java / kotlin] xrp ledger 연동

·

6 min read

업무를 진행하면서 우리 메인넷과 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

  1. account_info 조회

  2. keypair 생성( by Seed )

  3. payment request 객체생성

  4. sign

  5. 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 사용


[참고]

https://xrpl.org/http-websocket-apis.html