Quorum是基于以太坊的Golang实现go-ethereum开发而来。 详细的可参考如下链接:
在go-ethereum基础上,Quorum主要做了如下扩展:
Quorum有两个组件:
整体架构如下:
Quorum的本质,是使用密码学技术来防止交易方以外的人看到敏感数据。对于私有交易,会进行加密处理,公链(Quorum chain)上只存储加密后的数据的hash值,而私有交易的数据加密后将存储在链下,通过定制的一个模块(Tessera或者Constellation)在节点间安全的共享。状态数据库被分成私有状态数据库和公开状态数据库两类。网络中所有节点的公开状态,均完美达成状态共识,而私有状态数据库的情况有所不同,将不再保存整个全局私有状态数据库的状态,如下图所示:
(1)公开合约、交易
与go-ethereum基本保持一致
(2)私有合约、交易
通过privateFor参数标识合约为私有合约,参数的值为私有合约的其他参与者的公钥,如下所示:
var simple = simpleContract.new(42, {from:web3.eth.accounts[0], data: bytecode, gas: 0x47b760, privateFor: ["ROAZBWtSacxXQrOe3FGAqJDyJjFePR5ce4TSIzmJ0Bc="]}, function(e, contract) {
if (e) {
console.log("err creating contract", e);
} else {
if (!contract.address) {
console.log("Contract transaction send: TransactionHash: " + contract.transactionHash + " waiting to be mined...");
} else {
console.log("Contract mined! Address: " + contract.address);
console.log(contract);
}
}
});
通过privateFor参数标识交易为私有交易,参数的值为私有交易的其他参与者的公钥,如下所示:
{
"jsonrpc": "2.0",
"method": "eth_sendTransaction",
"params": [
{
"from": "$FROM_AC",
"to": "$TO_AC",
"data": "$CODEHASH",
"privateFor": [
"$PUBKEY1,PUBKEY2"
]
}
],
"id": "$ID"
}
七个节点的例子:7nodes
(1)使用docker-compose部署好区块链:
$git clone https://github.com/jpmorganchase/quorum-examples
$cd quorum-examples
$docker-compose up -d
$docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
19522838f213 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 30 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22006->8545/tcp quorum-examples_node7_1
6c05e4441202 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 28 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22002->8545/tcp quorum-examples_node3_1
e39674824a33 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 30 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22001->8545/tcp quorum-examples_node2_1
6df304600bde quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 30 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22005->8545/tcp quorum-examples_node6_1
3e05a8e19444 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 30 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22004->8545/tcp quorum-examples_node5_1
d0c109e234a7 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 30 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22003->8545/tcp quorum-examples_node4_1
d246eaea26f6 quorumengineering/quorum:2.2.1 "/bin/sh -c 'UDS_WAI…" 30 minutes ago Up 28 minutes (healthy) 8546/tcp, 21000/tcp, 30303/tcp, 50400/tcp, 30303/udp, 0.0.0.0:22000->8545/tcp quorum-examples_node1_1
764e0941d9b3 quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9087->9080/tcp quorum-examples_txmanager7_1
54bc0ed8a974 quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9086->9080/tcp quorum-examples_txmanager6_1
5495f660a2d2 quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9084->9080/tcp quorum-examples_txmanager4_1
ec124de173a8 quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9082->9080/tcp quorum-examples_txmanager2_1
faa801660985 quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9085->9080/tcp quorum-examples_txmanager5_1
a2cf351d787a quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9083->9080/tcp quorum-examples_txmanager3_1
7931f1f1c14e quorumengineering/tessera:0.8 "/bin/sh -c 'DDIR=/q…" 31 minutes ago Up 30 minutes (healthy) 9000/tcp, 0.0.0.0:9081->9080/tcp quorum-examples_txmanager1_1
(2)在节点1和节点7之间部署私有智能合约
attach到节点1上部署智能合约,其中privateFor为节点7的公钥:
a = eth.accounts[0]
web3.eth.defaultAccount = a;
// abi and bytecode generated from simplestorage.sol:
// > solcjs --bin --abi simplestorage.sol
var abi = [{"constant":true,"inputs":[],"name":"storedData","outputs":[{"name":"","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"x","type":"uint256"}],"name":"set","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"get","outputs":[{"name":"retVal","type":"uint256"}],"payable":false,"type":"function"},{"inputs":[{"name":"initVal","type":"uint256"}],"payable":false,"type":"constructor"}];
var bytecode = "0x6060604052341561000f57600080fd5b604051602080610149833981016040528080519060200190919050505b806000819055505b505b610104806100456000396000f30060606040526000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680632a1afcd914605157806360fe47b11460775780636d4ce63c146097575b600080fd5b3415605b57600080fd5b606160bd565b6040518082815260200191505060405180910390f35b3415608157600080fd5b6095600480803590602001909190505060c3565b005b341560a157600080fd5b60a760ce565b6040518082815260200191505060405180910390f35b60005481565b806000819055505b50565b6000805490505b905600a165627a7a72305820d5851baab720bba574474de3d09dbeaabc674a15f4dd93b974908476542c23f00029";
var simpleContract = web3.eth.contract(abi);
var simple = simpleContract.new(42, {from:web3.eth.accounts[0], data: bytecode, gas: 0x47b760, privateFor: ["ROAZBWtSacxXQrOe3FGAqJDyJjFePR5ce4TSIzmJ0Bc="]}, function(e, contract) {
if (e) {
console.log("err creating contract", e);
} else {
if (!contract.address) {
console.log("Contract transaction send: TransactionHash: " + contract.transactionHash + " waiting to be mined...");
} else {
console.log("Contract mined! Address: " + contract.address);
console.log(contract);
}
}
});
部署的智能合约为一个简单的数据存取的合约:
pragma solidity ^0.4.15;
contract simplestorage {
uint public storedData;
function simplestorage(uint initVal) {
storedData = initVal;
}
function set(uint x) {
storedData = x;
}
function get() constant returns (uint retVal) {
return storedData;
}
}
(3)对于公开交易,则直接在Quorum节点间完成,与go-ethereum基本一致,如下:
(4)对于私有交易
例如节点1上set(4),分别在节点4和节点7上get()则有不同现象:
// Node 1
> private.set(4,{from:eth.accounts[0],privateFor:["ROAZBWtSacxXQrOe3FGAqJDyJjFePR5ce4TSIzmJ0Bc="]});
"0x678f838f0e05187228ea3c890f1feeff2d0e948da5de699392b0dcec3c0eee59"
> private.get()
4
// Node 4
> private.get()
0
// Node 7
> private.get()
4
可以发现,私有交易的双方,即节点1和7都能正确获取,而非交易方节点4则无法正确获取,从而实现交易的隐私性。若在这三个节点上查询此交易0x678f838f0e05187228ea3c890f1feeff2d0e948da5de699392b0dcec3c0eee59,会发现都能查到,只不过交易的input参数的值不再是原始交易的payload,而是加密后的payload的hash值,且value是一个特殊的值,用来标识此交易为私有交易。如下图所示:
交易真实的payload会通过TransactionManager安全的在节点间共享,Quorum节点通过加密后的payload的hash值作为索引在与之关联的Tessera或者Constellation节点上获取解密后的payload,从而执行交易。如下图所示:
在这个案例中,A机构和B机构构成了私有交易AB的交易双方,而C机构不参与该交易。
Quorum组件基于go-ethereum修改:
当发送私有交易时,即添加privateFor参数时,Quorum节点会检测到这个是一个私有交易,如下:
// SendTransaction will create a transaction from the given arguments and
// tries to sign it with the key associated with args.To. If the given passwd isn't
// able to decrypt the key it fails.
func (s *PrivateAccountAPI) SendTransaction(ctx context.Context, args SendTxArgs, passwd string) (common.Hash, error) {
// Look up the wallet containing the requested signer
account := accounts.Account{Address: args.From}
wallet, err := s.am.Find(account)
if err != nil {
return common.Hash{}, err
}
if args.Nonce == nil {
// Hold the addresse's mutex around signing to prevent concurrent assignment of
// the same nonce to multiple accounts.
s.nonceLock.LockAddr(args.From)
defer s.nonceLock.UnlockAddr(args.From)
}
isPrivate := args.PrivateFor != nil
if isPrivate { // Quorum节点会检测到这个是一个私有交易
data := []byte(*args.Data)
if len(data) > 0 {
log.Info("sending private tx", "data", fmt.Sprintf("%x", data), "privatefrom", args.PrivateFrom, "privatefor", args.PrivateFor)
// 向Tessera组件发送交易数据,Tessera组件返回加密后的交易数据的hash值
data, err := private.P.Send(data, args.PrivateFrom, args.PrivateFor)
log.Info("sent private tx", "data", fmt.Sprintf("%x", data), "privatefrom", args.PrivateFrom, "privatefor", args.PrivateFor)
if err != nil {
return common.Hash{}, err
}
}
// zekun: HACK
d := hexutil.Bytes(data)
args.Data = &d
}
// Set some sanity defaults and terminate on failure
if err := args.setDefaults(ctx, s.b); err != nil {
return common.Hash{}, err
}
// Assemble the transaction and sign with the wallet
tx := args.toTransaction()
var chainID *big.Int
if config := s.b.ChainConfig(); config.IsEIP155(s.b.CurrentBlock().Number()) && !isPrivate {
chainID = config.ChainID
}
signed, err := wallet.SignTxWithPassphrase(account, passwd, tx, chainID)
if err != nil {
return common.Hash{}, err
}
return submitTransaction(ctx, s.b, signed, isPrivate)
}
Quorum节点就会向Tessera组件发送交易数据,Tessera组件返回加密后的交易数据的hash值。
func (g *Constellation) Send(data []byte, from string, to []string) (out []byte, err error) {
if g.isConstellationNotInUse {
return nil, ErrConstellationIsntInit
}
out, err = g.node.SendPayload(data, from, to)
if err != nil {
return nil, err
}
g.c.Set(string(out), data, cache.DefaultExpiration)
return out, nil
}
Quorum节点与Tessera的通讯是使用的基于Unix Domain Socket的Private API
func (c *Client) SendPayload(pl []byte, b64From string, b64To []string) ([]byte, error) {
buf := bytes.NewBuffer(pl)
req, err := http.NewRequest("POST", "http+unix://c/sendraw", buf)
if err != nil {
return nil, err
}
if b64From != "" {
req.Header.Set("c11n-from", b64From)
}
req.Header.Set("c11n-to", strings.Join(b64To, ","))
req.Header.Set("Content-Type", "application/octet-stream")
res, err := c.httpClient.Do(req)
if res != nil {
defer res.Body.Close()
}
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("Non-200 status code: %+v", res)
}
return ioutil.ReadAll(base64.NewDecoder(base64.StdEncoding, res.Body))
}
这样以后,在Quorum链上存储的就是加密后的私有交易的hash值,而实际的交易内容则被Tessera安全的存储在DB内。
当出块时,在CommitTransactions阶段会进行交易的执行,如下:
其中tx的执行在TransitionDb中:
// TransitionDb will transition the state by applying the current message and
// returning the result including the the used gas. It returns an error if it
// failed. An error indicates a consensus issue.
func (st *StateTransition) TransitionDb() (ret []byte, usedGas uint64, failed bool, err error) {
if err = st.preCheck(); err != nil {
return
}
msg := st.msg
sender := vm.AccountRef(msg.From())
homestead := st.evm.ChainConfig().IsHomestead(st.evm.BlockNumber)
contractCreation := msg.To() == nil
isQuorum := st.evm.ChainConfig().IsQuorum
var data []byte
isPrivate := false
publicState := st.state
// 私有交易
if msg, ok := msg.(PrivateMessage); ok && isQuorum && msg.IsPrivate() {
isPrivate = true
// 向Tessera发起请求获取解密后的交易数据
data, err = private.P.Receive(st.data)
// Increment the public account nonce if:
// 1. Tx is private and *not* a participant of the group and either call or create
// 2. Tx is private we are part of the group and is a call
if err != nil || !contractCreation {
publicState.SetNonce(sender.Address(), publicState.GetNonce(sender.Address())+1)
}
if err != nil {
return nil, 0, false, nil
}
} else {
data = st.data
}
// Pay intrinsic gas
gas, err := IntrinsicGas(st.data, contractCreation, homestead)
if err != nil {
return nil, 0, false, err
}
if err = st.useGas(gas); err != nil {
return nil, 0, false, err
}
var (
evm = st.evm
// vm errors do not effect consensus and are therefor
// not assigned to err, except for insufficient balance
// error.
vmerr error
)
if contractCreation {
ret, _, st.gas, vmerr = evm.Create(sender, data, st.gas, st.value)
} else {
// Increment the account nonce only if the transaction isn't private.
// If the transaction is private it has already been incremented on
// the public state.
if !isPrivate {
publicState.SetNonce(msg.From(), publicState.GetNonce(sender.Address())+1)
}
var to common.Address
if isQuorum {
to = *st.msg.To()
} else {
to = st.to()
}
//if input is empty for the smart contract call, return
if len(data) == 0 && isPrivate {
return nil, 0, false, nil
}
ret, st.gas, vmerr = evm.Call(sender, to, data, st.gas, st.value)
}
if vmerr != nil {
log.Info("VM returned with error", "err", vmerr)
// The only possible consensus-error would be if there wasn't
// sufficient balance to make the transfer happen. The first
// balance transfer may never fail.
if vmerr == vm.ErrInsufficientBalance {
return nil, 0, false, vmerr
}
}
st.refundGas()
st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))
if isPrivate {
return ret, 0, vmerr != nil, err
}
return ret, st.gasUsed(), vmerr != nil, err
}
如果是私有交易,Quorum节点则会向Tessera发起请求,以Quorum链上存储的data为key,即之前加密后的交易数据的hash值,获取解密后的交易的实际数据,然后在evm中执行交易。
Tessera组件是Transaction Manager的java实现,详见:https://github.com/jpmorganchase/tessera/wiki
由两个部分组成:
Quorum采用了基于Raft的共识机制(使用etcd的Raft实现),而不是以太坊默认的PoW方案。这对于不需要拜占庭容错并且需要更快出块时间(以毫秒而非秒为单位)和事务结束(不存在分支)的封闭式成员资格/联盟设置非常有效。
在以太坊中,任意节点都可以作为区块的打包者,只要其在一轮 pow 中胜出。我们知道 Quorum 的节点沿用了以太坊的设计和代码。所以为了连接以太坊节点和 Raft 共识,Quorum 采用了网络节点和 Raft 节点一对一的方式来实现 Raft-based 共识。当一笔 TX 诞生后,TX 会在以太坊的 P2P 网络中传输。同时,Raft 的 leader 竞选一直在同步进行。当前 leader 节点对应的以太坊节点收到 TX 时,以太坊节点就会将 TX 打包成区块并将区块通过 Raft 节点发送给 Raft 网络上的 follower。follower 节点收到区块后将区块交给对应的以太坊节点。然后以太坊节点将区块记录到链上。
与以太坊不同的是,当一个节点收到区块后并不会马上记录到链上。而是等 Raft 网络中的 leader 收到所有 follower 的确认回执后,广播一个执行的消息。然后所有收到执行消息的 follower 才会将区块记录在本地的链上。
通常情况下,每一个被传至 Raft 的区块最终都会被添加到链上。但是也会有意外出现。比如因为一些网络的原因,某个 leader 无法与大部分的 follower 进行交互了。这时其他 follower 就会推选出新的 leader。在这期间,旧的 leader 还会产生新的区块。但是因为没有收到足量的 follower 回执,所以它产生的区块都不会最终写到链上。与之相对的,新的 leader 这边则会正常进行区块同步。一旦旧 leader 这边恢复通信,它会将自己产生的 AppendEntries 指令广播出去。由于其发出的指令已经过时了,所以大部分的 follower 不会给予这些指令正确的回执。
具体流程如下:
得到区块[0x002, parent: 0x001, sender: Node1] - 执行
得到区块[0x004, parent: 0x002, sender: Node2] - 执行
得到区块[0x003, parent: 0x002, sender: Node1] - 不执行
得到区块[0x005, parent: 0x004, sender: Node2] - 执行
需要注意的是,整个共识过程中,Raft 层面只负责记录自己节点的 Raft log。真正执行 log 内容的是 Quorum 节点。Quorum 节点根据其节点对应的 Raft log 来做具体的操作。
一个区块从被创建,到经过 Raft 同步,到最后记录到链上多多少少会经历一段时间(尽管非常短)。如果等上一个区块写入到链上以后下一个区块才能生成,那么就会使得 TX 的确认时间增长。为了解决这个问题,同时为了能更有效率的处理区块生成,Quorum 推出了 speculative minting 机制。在这种机制下,新区块可以在其父区块没有完全上链的情况下被创建。如果这个场景重复出现,那么就会出现一串未被上链的区块,这些区块都会有指向其父区块的索引,我们将这类区块串称为 speculative chain。
在维护 speculative chain 的同时,系统还会维护一份被称作 proposedTxes 的数组。这份数组包含了所有 speculative chain 中的 TX。主要是为了记录已经被传输到 Raft 中但是还没被正式上链的交易。防止同一条交易被重复打包。
详见:https://github.com/ethereum/EIPs/issues/650
节点的授权,是用来控制哪些节点可以连接到指定节点、以及可以从哪些指定节点拨出的功能。目前,当启动节点时,通过–permissioned参数在节点级别处进行管理。
如果设置了–permissioned参数,节点将查找名为/permissioned-nodes.json的文件。此文件包含此节点可以连接并接受来自其连接的enodes白名单。因此,启用权限后,只有permissioned-nodes.json文件中列出的节点成为网络的一部分。 如果指定了–permissioned参数,但没有节点添加到permissioned-nodes.json文件,则该节点既不能连接到任何节点也不能接受任何接入的连接。
permissioned-nodes.json文件的格式如下所示
[
"enode://remoteky1@ip1:port1",
"enode://remoteky1@ip2:port2",
"enode://remoteky1@ip3:port3"
]
在geth建立p2p连接的时候,如果启用了节点的许可管理,则会调用isNodePermissioned方法去检查目标节点是否被授权,如下所示:
func (srv *Server) setupConn(c *conn, flags connFlag, dialDest *discover.Node) error {
...
//START - QUORUM Permissioning
currentNode := srv.NodeInfo().ID
cnodeName := srv.NodeInfo().Name
clog.Trace("Quorum permissioning",
"EnableNodePermission", srv.EnableNodePermission,
"DataDir", srv.DataDir,
"Current Node ID", currentNode,
"Node Name", cnodeName,
"Dialed Dest", dialDest,
"Connection ID", c.id,
"Connection String", c.id.String())
if srv.EnableNodePermission {
clog.Trace("Node Permissioning is Enabled.")
node := c.id.String()
direction := "INCOMING"
if dialDest != nil {
node = dialDest.ID.String()
direction = "OUTGOING"
log.Trace("Node Permissioning", "Connection Direction", direction)
}
if !isNodePermissioned(node, currentNode, srv.DataDir, direction) {
return nil
}
} else {
clog.Trace("Node Permissioning is Disabled.")
}
//END - QUORUM Permissioning
...
}
在isNodePermissioned中则会遍历目标节点是否在permissioned-nodes.json的节点列表内,如下:
// check if a given node is permissioned to connect to the change
func isNodePermissioned(nodename string, currentNode string, datadir string, direction string) bool {
var permissionedList []string
nodes := parsePermissionedNodes(datadir)
for _, v := range nodes {
permissionedList = append(permissionedList, v.ID.String())
}
log.Debug("isNodePermissioned", "permissionedList", permissionedList)
for _, v := range permissionedList {
if v == nodename {
log.Debug("isNodePermissioned", "connection", direction, "nodename", nodename[:NODE_NAME_LENGTH], "ALLOWED-BY", currentNode[:NODE_NAME_LENGTH])
return true
}
}
log.Debug("isNodePermissioned", "connection", direction, "nodename", nodename[:NODE_NAME_LENGTH], "DENIED-BY", currentNode[:NODE_NAME_LENGTH])
return false
}
benchmark: https://github.com/drandreaskrueger/chainhammer
对比结果:
hardware | node type | #nodes | config | peak TPS_av | final TPS_av |
---|---|---|---|---|---|
t2.micro | parity aura | 4 | (D) | 45.5 | 44.3 |
t2.large | parity aura | 4 | (D) | 53.5 | 52.9 |
t2.xlarge | parity aura | 4 | (J) | 57.1 | 56.4 |
t2.2xlarge | parity aura | 4 | (D) | 57.6 | 57.6 |
t2.micro | parity instantseal | 1 | (G) | 42.3 | 42.3 |
t2.xlarge | parity instantseal | 1 | (J) | 48.1 | 48.1 |
t2.2xlarge | geth clique | 3+1 +2 | (B) | 421.6 | 400.0 |
t2.xlarge | geth clique | 3+1 +2 | (B) | 386.1 | 321.5 |
t2.xlarge | geth clique | 3+1 | (K) | 372.6 | 325.3 |
t2.large | geth clique | 3+1 +2 | (B) | 170.7 | 169.4 |
t2.small | geth clique | 3+1 +2 | (B) | 96.8 | 96.5 |
t2.micro | geth clique | 3+1 | (H) | 124.3 | 122.4 |
t2.micro SWAP | quorum crux IBFT | 4 | (I) SWAP! | 98.1 | 98.1 |
t2.micro | quorum crux IBFT | 4 | (F) | lack of RAM | |
t2.large | quorum crux IBFT | 4 | (F) | 207.7 | 199.9 |
t2.xlarge | quorum crux IBFT | 4 | (F) | 439.5 | 395.7 |
t2.xlarge | quorum crux IBFT | 4 | (L) | 389.1 | 338.9 |
t2.2xlarge | quorum crux IBFT | 4 | (F) | 435.4 | 423.1 |
c5.4xlarge | quorum crux IBFT | 4 | (F) | 536.4 | 524.3 |
(1)Raft
(2)IBFT