FeedPage

FeedPage는 'Klaystagram' 컨트랙트와 상호작용하는 3가지 주요 구성 요소로 이루어져 있습니다.
UploadPhoto 컴포넌트
Feed 컴포넌트
TransferOwnership 컴포넌트
// src/pages/FeedPage.jsconst FeedPage = () => (  <main className="FeedPage">    <UploadButton />               // 7-2. UploadPhoto    <Feed />                       // 7-3. Feed  </main>)
// src/components/Feed.js<div className="Feed">  {feed.length !== 0    ? feed.map((photo) => {      // ...      return (        <div className="FeedPhoto" key={id}>            // ...            {              userAddress.toUpperCase() === currentOwner.toUpperCase() && (                <TransferOwnershipButton   // 7-4. TransferOwnership                  className="FeedPhoto__transferOwnership"                  id={id}                  issueDate={issueDate}                  currentOwner={currentOwner}                />              )            }            // ...        </div>      )    })    : <span className="Feed__empty">No Photo :D</span>  }</div>)
컴포넌트가 컨트랙트와 상호작용하도록 만들려면 3단계가 있습니다.
첫번째, 컨트랙트와 프론트엔드를 연결하기 위해 KlaystagramContract 인스턴스를 생성합니다.\
두번째, KlaystagramContract 인스턴스를 사용하여 redux/actions에서 컨트랙트와 상호작용하는 API 함수를 만듭니다.\
세번째, 각 컴포넌트에서 함수를 호출합니다.
빌드해 봅시다!
1. 프런트엔드에 컨트랙트 연결하기 
- 
src/klaytn- caver.js
 - KlaystagramContract.js
 
 - 
src/redux 
1) src/klaytn 
src/klaytn: 클레이튼 블록체인과 상호작용하는 데 도움이 되는 파일들을 포함합니다.
- 
src/klaytn/caver.js: 설정된 설정 내에서 caver를 인스턴스화합니다.cf) caver-js는 클레이튼 노드에 연결하여 클레이튼에 배포된 노드 또는 스마트 컨트랙트와 상호작용하는 RPC 라이브러리입니다.
 - 
src/klaytn/Klaystagram.js: caver-js API를 사용하여 컨트랙트 인스턴스를 생성합니다. 인스턴스를 통해 컨트랙트와 상호작용할 수 있습니다. 
caver.js 
/** * caver-js library helps making connection with klaytn node. * You can connect to specific klaytn node by setting 'rpcURL' value. * default rpcURL is 'https://public-en-baobab.klaytn.net'. */import Caver from 'caver-js'export const config = {  rpcURL: 'https://public-en-baobab.klaytn.net'}export const cav = new Caver(config.rpcURL)export default cav
연결이 완료되면 caver로 스마트 컨트랙트에서 메서드를 호출할 수 있습니다.
KlaystagramContract.js 
// klaytn/KlaystagramContract.jsimport { cav } from 'klaytn/caver'/** * 1. Create contract instance * ex:) new cav.klay.Contract(DEPLOYED_ABI, DEPLOYED_ADDRESS) * You can call contract method through this instance. */const KlaystagramContract = DEPLOYED_ABI  && DEPLOYED_ADDRESS  && new cav.klay.Contract(DEPLOYED_ABI, DEPLOYED_ADDRESS)export default KlaystagramContract
컨트랙트와 상호작용하려면 컨트랙트 인스턴스가 필요합니다.
Klaystagram contract는 cav.klay.ContractAPI에DEPLOYED_ABI\(애플리케이션 바이너리 인터페이스\)와 DEPLOYED_ADDRESS`를 제공하여 Klaystagram 컨트랙트와 상호작용할 컨트랙트 인스턴스를 생성합니다.
Klaystagram.sol 컨트랙트를 컴파일하고 배포할 때 (5. 배포 컨트랙트) 이미 deployedABI와 deployedAddress 파일을 생성했습니다. 이 파일에는 Klaystagram 컨트랙트의 ABI와 배포된 컨트랙트 주소가 들어 있습니다.
그리고 웹팩의 설정 덕분에 변수(DEPLOYED_ADDRESS, DEPLOYED_ABI)로 액세스할 수 있습니다.
- 
DEPLOYED_ADDRESS는 배포된 주소를 반환합니다. - 
DEPLOYED_ABI는 Klaystagram contract ABI를 반환합니다. 
참고) contract ABI(애플리케이션 바이너리 인터페이스)
contract ABI는 컨트랙트 메서드를 호출하기 위한 인터페이스입니다. 이 인터페이스를 사용하면 다음과 같이 컨트랙트 메서드를 호출할 수 있습니다.
- 
contractInstance.methods.methodName().call() - 
contractInstance.methods.methodName().send({ ... }) 
이제 애플리케이션에서 컨트랙트와 상호작용할 준비가 되었습니다.
참고) 자세한 내용은 caver.klay.Contract를 참조하세요.
2) src/redux 
Klaystagram 인스턴스로 API 함수를 만들어 보겠습니다. API 함수를 호출한 후, 리덕스 스토어를 사용하여 모든 데이터 흐름을 제어합니다.
- 
컨트랙트 인스턴스 가져오기
KlaystagramContract인스턴스를 사용하면 컴포넌트가 컨트랙트와 상호작용해야 할 때 컨트랙트의 메서드를 호출할 수 있습니다. - 
컨트랙트 메서드 호출하기
 - 
컨트랙트에서 데이터 저장.
트랜잭션이 성공하면 리덕스 작업을 호출하여 컨트랙트에서 리덕스 스토어에 정보를 저장합니다.
 
// src/redux/actions/photos.js// 1. Import contract instanceimport KlaystagramContract from 'klaytn/KlaystagramContract'const setFeed = (feed) => ({  type: SET_FEED,  payload: { feed },})const updateFeed = (tokenId) => (dispatch, getState) => {  // 2. Call contract method (CALL): getPhoto()  KlaystagramContract.methods.getPhoto(tokenId).call()    .then((newPhoto) => {      const { photos: { feed } } = getState()      const newFeed = [feedParser(newPhoto), ...feed]      // 3. Store data from contract      dispatch(setFeed(newFeed))    })}
Redux 스토어는 프론트엔드에서 모든 데이터 흐름을 제어합니다.
2. UploadPhoto 컴포넌트 

UploadPhoto컴포넌트의 역할- 컴포넌트 코드.
 - 컨트랙트와의 상호작용.
 - 저장할 데이터를 업데이트합니다: 
updateFeed함수 
1) UploadPhoto 컴포넌트의 역할 
UploadPhoto 컴포넌트는 클레이튼 블록체인에 사진 업로드 요청을 처리합니다. 그 과정은 다음과 같습니다:
- 트랜잭션을 전송하여 스마트 컨트랙트의 
uploadPhoto메서드를 호출합니다.UploadPhoto컨트랙트 메서드 내에서 새로운 ERC-721 토큰이 발행됩니다. - 트랜잭션 전송 후, 트랜잭션 라이프사이클에 따른 진행 상황을 
Toast컴포넌트 를 사용하여 표시합니다. - 트랜잭션이 블록에 들어가면 로컬 리덕스 저장소에 새로운 
PhotoData를 업데이트합니다. 
콘텐츠 크기 제한\
단일 트랜잭션의 최대 크기는 32KB입니다. 따라서 안전하게 전송하기 위해 입력 데이터(사진 및 설명)가 30KB를 초과하지 않도록 제한합니다.
- 
문자열 데이터 크기는
2KB로 제한됩니다. - 
imageCompression()함수를 사용하여 사진을28KB미만으로 압축합니다. 
2. 컴포넌트 코드 
// src/components/UploadPhoto.jsimport React, { Component } from 'react'import { connect } from 'react-redux'import imageCompression from 'utils/imageCompression';import ui from 'utils/ui'import Input from 'components/Input'import InputFile from 'components/InputFile'import Textarea from 'components/Textarea'import Button from 'components/Button'import * as photoActions from 'redux/actions/photos'import './UploadPhoto.scss'// Set a limit of contentsconst MAX_IMAGE_SIZE = 30 * 1024 // 30KBconst MAX_IMAGE_SIZE_MB = 30 / 1024 // 30KBclass UploadPhoto extends Component {  state = {    file: '',    fileName: '',    location: '',    caption: '',    warningMessage: '',    isCompressing: false,  }  handleInputChange = (e) => {    this.setState({      [e.target.name]: e.target.value,    })  }  handleFileChange = (e) => {    const file = e.target.files[0]    /**     * If image size is bigger than MAX_IMAGE_SIZE(30KB),     * Compress the image to load it on transaction     * cf. Maximum transaction input data size: 32KB     */    if (file.size > MAX_IMAGE_SIZE) {      this.setState({        isCompressing: true,      })      return this.compressImage(file)    }    return this.setState({      file,      fileName: file.name,    })  }  handleSubmit = (e) => {    e.preventDefault()    const { file, fileName, location, caption } = this.state    this.props.uploadPhoto(file, fileName, location, caption)    ui.hideModal()  }  compressImage = async (imageFile) => {    try {      const compressedFile = await imageCompression(imageFile, MAX_IMAGE_SIZE_MB)      this.setState({        isCompressing: false,        file: compressedFile,        fileName: compressedFile.name,      })    } catch (error) {      this.setState({        isCompressing: false,        warningMessage: '* Fail to compress image'      })    }  }  render() {    const { fileName, location, caption, isCompressing, warningMessage } = this.state    return (      <form className="UploadPhoto" onSubmit={this.handleSubmit}>        <InputFile          className="UploadPhoto__file"          name="file"          label="Search file"          fileName={isCompressing ? 'Compressing image...' : fileName}          onChange={this.handleFileChange}          err={warningMessage}          accept=".png, .jpg, .jpeg"          required        />        <Input          className="UploadPhoto__location"          name="location"          label="Location"          value={location}          onChange={this.handleInputChange}          placeholder="Where did you take this photo?"          required        />        <Textarea          className="UploadPhoto__caption"          name="caption"          value={caption}          label="Caption"          onChange={this.handleInputChange}          placeholder="Upload your memories"          required        />        <Button          className="UploadPhoto__upload"          type="submit"          title="Upload"        />      </form>    )  }}const mapDispatchToProps = (dispatch) => ({  uploadPhoto: (file, fileName, location, caption) =>    dispatch(photoActions.uploadPhoto(file, fileName, location, caption)),})export default connect(null, mapDispatchToProps)(UploadPhoto)
3. 컨트랙트와 상호작용하기 
클레이튼에 사진 데이터를 쓰는 함수를 만들어 봅시다. 컨트랙트에 트랜잭션 보내기: uploadPhoto\
읽기 전용 함수 호출과 달리 데이터를 쓰면 트랜잭션 수수료가 발생합니다. 트랜잭션 수수료는 사용한 gas의 양에 따라 결정됩니다. gas는 트랜잭션을 처리하는 데 얼마나 많은 계산이 필요한지를 나타내는 측정 단위입니다.
이러한 이유로 트랜잭션을 전송하기 위해서는 두 개의 속성 from와 gas가 필요합니다.
- 
트랜잭션에 로드할 사진 파일을 바이트 문자열로 변환합니다.
(Klaystagram 컨트랙트에서는
PhotoData구조체에서 사진 fotmat을 바이트열로 정의했습니다.)FileReader를 사용하여 사진 데이터를 ArrayBuffer로 읽기- ArrayBuffer를 16진수 문자열로 변환합니다.
 - 바이트 형식을 만족시키기 위해 접두사 
0x를 추가합니다. 
 - 
컨트랙트 메서드 호출:
uploadPhotofrom: 이 트랜잭션을 전송하고 트랜잭션 수수료를 지불하는 계정입니다.gas: `from' 계정이 이 트랜잭션에 대해 지불하고자 하는 최대 가스 금액입니다.
 - 
트랜잭션 전송 후, 트랜잭션 라이프사이클에 따른 진행 상황을
Toast컴포넌트를 사용하여 표시합니다. - 
트랜잭션이 블록에 성공적으로 들어가면
updateFeed함수를 호출하여 피드 페이지에 새 사진을 추가합니다. 
// src/redux/actions/photo.jsexport const uploadPhoto = (  file,  fileName,  location,  caption) => (dispatch) => {  // 1. Convert photo file as a hex string to load on transaction  const reader = new window.FileReader()  reader.readAsArrayBuffer(file)  reader.onloadend = () => {    const buffer = Buffer.from(reader.result)    // Add prefix `0x` to hexString to recognize hexString as bytes by contract    const hexString = "0x" + buffer.toString('hex')    // 2. Invoke the contract method: uploadPhoto    // Send transaction with photo file(hexString) and descriptions    try{      KlaystagramContract.methods.uploadPhoto(hexString, fileName, location, caption).send({        from: getWallet().address,        gas: '200000000',      }, (error, txHash) => {        if (error) throw error;        // 3. After sending the transaction,        // show progress along the transaction lifecycle using `Toast` component.        ui.showToast({          status: 'pending',          message: `Sending a transaction... (uploadPhoto)`,          txHash,        })      })        .then((receipt) => {          ui.showToast({            status: receipt.status ? 'success' : 'fail',            message: `Received receipt! It means your transaction is            in klaytn block (#${receipt.blockNumber}) (uploadPhoto)`,            link: receipt.transactionHash,          })          // 4. If the transaction successfully gets into a block,          // call `updateFeed` function to add the new photo into the feed page.          if(receipt.status) {            const tokenId = receipt.events.PhotoUploaded.returnValues[0]            dispatch(updateFeed(tokenId))          }        })    } catch (error) {      ui.showToast({        status: 'error',        message: error.toString(),      })    }  }}
참고) 트랜잭션 라이프사이클
트랜잭션을 전송한 후 트랜잭션 라이프사이클(transactionHash, receipt, error)을 얻을 수 있습니다.
- 
서명된 트랜잭션 인스턴스가 제대로 구성되면
transactionHash이벤트가 발생합니다. 네트워크를 통 해 트랜잭션을 전송하기 전에 트랜잭션 해시를 얻을 수 있습니다. - 
트랜잭션 영수증을 받으면
receipt이벤트가 발생합니다. 트랜잭션이 블록에 포함되었다는 의미입니다. 블록 번호는receipt.blockNumber로 확인할 수 있습니다. - 
문제가 발생하면
error이벤트가 발생합니다. 
4. 피드 페이지에서 사진 업데이트: updateFeed 
트랜잭션을 컨트랙트에 성공적으로 전송한 후 FeedPage를 업데이트해야 합니다.
사진 피드를 업데이트하려면 방금 업로드한 새 사진 데이터를 가져와야 합니다. tokenId로 getPhoto()를 호출해 보겠습니다. tokenId는 트랜잭션 영수증에서 검색할 수 있습니다. 그런 다음 로컬 리덕스 저장소에 새 사진 데이터를 추가합니다.
// src/redux/actions/photo.js/** * 1. Call contract method: getPhoto() * To get new photo data we've just uploaded, * call `getPhoto()` with tokenId from receipt after sending transaction*/const updateFeed = (tokenId) => (dispatch, getState) => {  KlaystagramContract.methods.getPhoto(tokenId).call()    .then((newPhoto) => {      const { photos: { feed } } = getState()      const newFeed = [feedParser(newPhoto), ...feed]      // 2. update new feed to store      dispatch(setFeed(newFeed))    })}
3. Feed 컴포넌트 

Feed컴포넌트의 역할- 컨트랙트에서 데이터 읽기: 
getFeed메서드 - 저장할 데이터 저장: 
setFeed액션 - 컴포넌트에 데이터 표시: 
Feed컴포넌트 
1) Feed 컴포넌트의 역할 
4. Klaystagram 스마트 컨트랙트 작성하기에서 PhotoData 구조체를 작성하여 _photoList 매핑 안에 위치시켰습니다. 피드 컴포넌트의 역할은 다음과 같습니다:
- 클레이스타그램 컨트랙트 메서드 호출을 통해 
PhotoData를 읽습니다(redux/actions/photos.js) - 소유자 정보와 함께 
PhotoData(피드)를 표시합니다(components/Feed.js). 
2) 컨트랙트에서 데이터 읽기: getPhoto 메서드 
- 
컨트랙트 메서드 호출:
getTotalPhotoCount()사진이 0장이면 빈 배열로
setFeed액션을 호출합니다. - 
컨트랙트 메서드 호출:
getPhoto(id)사진이 있으면 각 사진 데이터를 프로미스로 가져와서 피드 배열 에 푸시합니다. 모든 프로미스가 해결되면 피드 배열을 반환합니다.
 - 
리덕스 액션 호출:
setFeed(feed)해결된 피드 배열을 가져와 리덕스 저장소에 저장합니다.
 
// src/redux/actions/photos.jsconst setFeed = (feed) => ({  type: SET_FEED,  payload: { feed },})export const getFeed = () => (dispatch) => {  // 1. Call contract method(READ): `getTotalPhotoCount()`  // If there is no photo data, call `setFeed` action with empty array  KlaystagramContract.methods.getTotalPhotoCount().call()    .then((totalPhotoCount) => {      if (!totalPhotoCount) return []      const feed = []      for (let i = totalPhotoCount; i > 0; i--) {        // 2. Call contract method(READ):`getPhoto(id)`        // If there is photo data, call all of them        const photo = KlaystagramContract.methods.getPhoto(i).call()        feed.push(photo)      }      return Promise.all(feed)    })    .then((feed) => {      // 3. Call actions: `setFeed(feed)`      // Save photo data(feed) to store      dispatch(setFeed(feedParser(feed))    })}
3. 저장할 데이터 저장: setFeed 액션 
Klaystagram 컨트랙트에서 사진 데이터(피드)를 성공적으로 가져온 후 setFeed(feed) 액션을 호출합니다. 이 액션은 사진 데이터를 페이로드로 가져와서 리덕스 저장소에 저장합니다.
4. 컴포넌트에 데이터 표시: Feed 컴포넌트 
// src/components/Feed.jsimport React, { Component } from 'react'import { connect } from 'react-redux'import moment from 'moment'import Loading from 'components/Loading'import PhotoHeader from 'components/PhotoHeader'import PhotoInfo from 'components/PhotoInfo'import CopyrightInfo from 'components/CopyrightInfo'import TransferOwnershipButton from 'components/TransferOwnershipButton'import { drawImageFromBytes} from 'utils/imageUtils'import { last } from 'utils/misc'import * as photoActions from 'redux/actions/photos'import './Feed.scss'class Feed extends Component {  constructor(props) {    super(props)    this.state = {      isLoading: !props.feed,    }  }  static getDerivedStateFromProps = (nextProps, prevState) => {    const isUpdatedFeed = (nextProps.feed !== prevState.feed) && (nextProps.feed !== null)    if (isUpdatedFeed) {      return { isLoading: false }    }    return null  }  componentDidMount() {    const { feed, getFeed } = this.props    if (!feed) getFeed()  }  render() {    const { feed, userAddress } = this.props    if (this.state.isLoading) return <Loading />    return (      <div className="Feed">        {feed.length !== 0          ? feed.map(({            id,            ownerHistory,            data,            name,            location,            caption,            timestamp,          }) => {            const originalOwner = ownerHistory[0]            const currentOwner = last(ownerHistory)            const imageUrl = drawImageFromBytes(data)            const issueDate = moment(timestamp * 1000).fromNow()            return (              <div className="FeedPhoto" key={id}>                <PhotoHeader                  currentOwner={currentOwner}                  location={location}                />                <div className="FeedPhoto__image">                  <img src={imageUrl} alt={name} />                </div>                <div className="FeedPhoto__info">                  <PhotoInfo                    name={name}                    issueDate={issueDate}                    caption={caption}                  />                  <CopyrightInfo                    className="FeedPhoto__copyrightInfo"                    id={id}                    issueDate={issueDate}                    originalOwner={originalOwner}                    currentOwner={currentOwner}                  />                  {                    userAddress.toUpperCase() === currentOwner.toUpperCase() && (                      <TransferOwnershipButton                        className="FeedPhoto__transferOwnership"                        id={id}                        issueDate={issueDate}                        currentOwner={currentOwner}                      />                    )                  }                </div>              </div>            )          })          : <span className="Feed__empty">No Photo :D</span>        }      </div>    )  }}const mapStateToProps = (state) => ({  feed: state.photos.feed,  userAddress: state.auth.address,})const mapDispatchToProps = (dispatch) => ({  getFeed: () => dispatch(photoActions.getFeed()),})export default connect(mapStateToProps, mapDispatchToProps)(Feed)
처음엔 아직 컨트랙트에 사진 데이터가 없기 때문에 "사진 없음 :D"라는 텍스트만 보입니다.
사진 데이터를 컨트랙트로 전송하는 UploadPhoto 컴포넌트를 만들어 봅시다!
4. TransferOwnership 컴포넌트 

- 
TransferOwnership컴포넌트의 역할 - 
컴포넌트 코드
2-1.
TransferOwnership버튼 렌더링하기2-2.
TransferOwnership컴포넌트 - 
컨트랙트와 상호작용:
transferOwnership메서드 - 
저장할 데이터를 업데이트합니다:
updateOwnerAddress액션 
1) TransferOwnership 컴포넌트의 역할 
사진 소유자는 사진의 소유권을 다른 사용자에게 양도할 수 있습니다. 트랜스퍼오너십` 트랜잭션을 전송하면 새로운 소유자의 주소가 소유권 기록에 저장되어 과거 소유자 주소를 추적할 수 있습니다.
2. 컴포넌트 코드 
2-1) TransferOwnership 버튼 렌더링 
사진의 소유자 주소가 로그인한 사용자의 주소와 일치하는 경우(즉, 사용자가 소유자라는 의미)에만 FeedPhoto 컴포넌트에 TransferOwnership 버튼을 렌더링하  겠습니다.
// src/components/Feed.js<div className="FeedPhoto">  // ...  {    userAddress.toUpperCase() === currentOwner.toUpperCase() && (      <TransferOwnershipButton        className="FeedPhoto__transferOwnership"        id={id}        issueDate={issueDate}        currentOwner={currentOwner}      />    )  }  // ...</div>
2-2) TransferOwnership 컴포넌트 
// src/components/TransferOwnership.jsimport React, { Component } from 'react'import { connect } from 'react-redux'import * as photoActions from 'redux/actions/photos'import ui from 'utils/ui'import { isValidAddress } from 'utils/crypto'import Input from 'components/Input'import Button from 'components/Button'import './TransferOwnership.scss'class TransferOwnership extends Component {  state = {    to: null,    warningMessage: '',  }  handleInputChange = (e) => {    this.setState({      [e.target.name]: e.target.value,    })  }  handleSubmit = (e) => {    e.preventDefault()    const { id, transferOwnership } = this.props    const { to } = this.state    if (!isValidAddress(to)) {      return this.setState({        warningMessage: '* Invalid wallet address',      })    }    transferOwnership(id, to)    ui.hideModal()  }  render() {    const { id, issueDate, currentOwner } = this.props    return (      <div className="TransferOwnership">        <h3 className="TransferOwnership__copyright">Copyright. {id}</h3>        <p className="TransferOwnership__issueDate">Issue Date  {issueDate}</p>        <form className="TransferOwnership__form" onSubmit={this.handleSubmit}>          <Input            className="TransferOwnership__from"            name="from"            label="Current Owner"            value={currentOwner}            readOnly          />          <Input            className="TransferOwnership__to"            name="to"            label="New Owner"            onChange={this.handleInputChange}            placeholder="Transfer Ownership to..."            err={this.state.warningMessage}            required          />          <Button            type="submit"            title="Transfer Ownership"          />        </form>      </div>    )  }}const mapDispatchToProps = (dispatch) => ({  transferOwnership: (id, to) => dispatch(photoActions.transferOwnership(id, to)),})export default connect(null, mapDispatchToProps)(TransferOwnership)
3. 컨트랙트와 상호작용하기: transferOwnership 메서드 
이미 4. Klaystagram 스마트 컨트랙트 작성하기에서 Klaystagram 컨트랙트에 transferOwnership 함수를 만들었습니다. 애플리케이션에서 호출해 봅시다.
- 컨트랙트 메서드 호출: 
transferOwnershipid:사진의 토큰아이디to:사진의 소유권을 이전할 주소
 - 트랜잭션 옵션 설정
from: 트랜잭션을 전송하고 트랜잭션 수수료를 지불할 계정입니다.gas: 발신자` 계정이 이 트랜잭션에 대해 지불할 수 있는 최대 가스 금액입니다.
 - 트랜잭션 전송 후,  트랜잭션 라이프사이클에 따른 진행 상황을 
Toast컴포넌트를 사용하여 표시합니다. - 트랜잭션이 블록에 성공적으로 들어가면 
updateOwnerAddress함수를 호출하여 새로운 소유자의 주소를 피드 페이지에 업데이트합니다. 
// src/redux/actions/photo.jsexport const transferOwnership = (tokenId, to) => (dispatch) => {  // 1. Invoke the contract method: transferOwnership  try{    KlaystagramContract.methods.transferOwnership(tokenId, to).send({            // 2. Set transaction options      from: getWallet().address,      gas: '20000000',    }, (error, txHash) => {      if (error) throw error;      // 3. After sending the transaction,      // show progress along the transaction lifecycle using `Toast` component.      ui.showToast({        status: 'pending',        message: `Sending a transaction... (transferOwnership)`,        txHash,      })    })      .then((receipt) => {        ui.showToast({          status: receipt.status ? 'success' : 'fail',          message: `Received receipt! It means your transaction is            in klaytn block (#${receipt.blockNumber}) (transferOwnership)`,          link: receipt.transactionHash,        })        // 4. If the transaction successfully gets into a block,        // call `updateOwnerAddress` function to update new owner's address into the feed page.        dispatch(updateOwnerAddress(tokenId, to))      })  } catch (error) {    ui.showToast({      status: 'error',      message: error.toString(),    })  }}
4. 리덕스 스토어에서 정보 업데이트: updateOwnerAddress 액션 
소유권을 이전한 후에는 새 소유자의 주소로 피드포토를 다시 렌더링해야 합니다.
새 소유자의 주소를 업데이트하려면 스토어에서 feed 데이터를 호출하여 영수증에서 토큰아이디가 있는 사진을 찾습니다. 그런 다음 새 소유자의 주소를 사진의 ownerHistory에 푸시하고 setFeed를 호출합니다.
const updateOwnerAddress = (tokenId, to) => (dispatch, getState) => {  const { photos: { feed } } = getState()  const newFeed = feed.map((photo) => {    if (photo['id'] !== tokenId) return photo    photo['ownerHistory'] = [...photo['ownerHistory'], to]    return photo  })  dispatch(setFeed(newFeed))}